mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(scanner): handle watched moves across docker libraries
Emit transfer events when watcher reconciliation moves a book between libraries. Keep file-as-book and folder-as-book watcher paths consistent across Docker, direct runs, and delayed copy-delete moves. Closes #386
This commit is contained in:
+3
-2
@@ -47,7 +47,7 @@ ENV KOBO_CLOUDSCRAPER_PYTHON=/opt/bookorbit-python/bin/python
|
||||
COPY server/requirements/kobo-cloudscraper.txt /tmp/kobo-cloudscraper-requirements.txt
|
||||
|
||||
RUN apk upgrade --no-cache && \
|
||||
apk add --no-cache poppler-utils su-exec ffmpeg python3 py3-pip && \
|
||||
apk add --no-cache poppler-utils su-exec ffmpeg python3 py3-pip tini && \
|
||||
python3 -m venv /opt/bookorbit-python && \
|
||||
/opt/bookorbit-python/bin/python -m pip install --no-cache-dir -r /tmp/kobo-cloudscraper-requirements.txt && \
|
||||
rm -f /tmp/kobo-cloudscraper-requirements.txt && \
|
||||
@@ -66,6 +66,7 @@ RUN chmod +x /app/entrypoint.sh /app/bin/kepubify/* && mkdir -p /books /data/cov
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD node -e "const p=process.env.PORT||3000;fetch('http://127.0.0.1:'+p+'/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
CMD ["node", "-e", "const p=process.env.PORT||3000;fetch('http://127.0.0.1:'+p+'/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "-s", "--"]
|
||||
CMD ["sh", "/app/entrypoint.sh"]
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { EventEmitter } from 'events'
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { BookTransferredEvent } from '@bookorbit/types'
|
||||
|
||||
// ── Hoisted mocks ─────────────────────────────────────────────────────────────
|
||||
// vi.mock factories are hoisted before any const/let, so all references inside
|
||||
@@ -145,3 +148,82 @@ describe('onBookRestored', () => {
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
// ── onBookMoved ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('onBookMoved', () => {
|
||||
it('fires the callback with the bookIds array on book:moved event', () => {
|
||||
const { onBookMoved } = useBookEvents()
|
||||
const cb = vi.fn<(bookIds: number[]) => void>()
|
||||
const cleanup = onBookMoved(cb)
|
||||
|
||||
mockSocket.emit('book:moved', { libraryId: 1, bookIds: [44, 45] })
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1)
|
||||
expect(cb).toHaveBeenCalledWith([44, 45])
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('shows info toast for moved books after 1s debounce', () => {
|
||||
const { onBookMoved } = useBookEvents()
|
||||
const cleanup = onBookMoved(() => {})
|
||||
|
||||
mockSocket.emit('book:moved', { libraryId: 1, bookIds: [1] })
|
||||
mockSocket.emit('book:moved', { libraryId: 1, bookIds: [2, 3] })
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
expect(toastInfo).toHaveBeenCalledTimes(1)
|
||||
expect(toastInfo).toHaveBeenCalledWith('3 books were moved to new locations.')
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
// ── onBookTransferred ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('onBookTransferred', () => {
|
||||
it('fires the callback with the full transfer event without showing a toast', () => {
|
||||
const { onBookTransferred } = useBookEvents()
|
||||
const cb = vi.fn<(event: BookTransferredEvent) => void>()
|
||||
const cleanup = onBookTransferred(cb)
|
||||
const event: BookTransferredEvent = { fromLibraryId: 1, toLibraryId: 2, bookIds: [77] }
|
||||
|
||||
mockSocket.emit('book:transferred', event)
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1)
|
||||
expect(cb).toHaveBeenCalledWith(event)
|
||||
expect(toastWarning).not.toHaveBeenCalled()
|
||||
expect(toastSuccess).not.toHaveBeenCalled()
|
||||
expect(toastInfo).not.toHaveBeenCalled()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('cleanup function deregisters the transferred callback', () => {
|
||||
const { onBookTransferred } = useBookEvents()
|
||||
const cb = vi.fn<(event: BookTransferredEvent) => void>()
|
||||
const cleanup = onBookTransferred(cb)
|
||||
cleanup()
|
||||
|
||||
mockSocket.emit('book:transferred', { fromLibraryId: 1, toLibraryId: 2, bookIds: [77] })
|
||||
|
||||
expect(cb).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('automatically deregisters callbacks registered during component setup on unmount', () => {
|
||||
const cb = vi.fn<(event: BookTransferredEvent) => void>()
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
useBookEvents().onBookTransferred(cb)
|
||||
return () => null
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(Component)
|
||||
wrapper.unmount()
|
||||
|
||||
mockSocket.emit('book:transferred', { fromLibraryId: 1, toLibraryId: 2, bookIds: [77] })
|
||||
|
||||
expect(cb).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { BookMissingEvent, BookMovedEvent, BookRestoredEvent } from '@bookorbit/types'
|
||||
import { getCurrentInstance, onUnmounted } from 'vue'
|
||||
import type { BookMissingEvent, BookMovedEvent, BookRestoredEvent, BookTransferredEvent } from '@bookorbit/types'
|
||||
import { getSocket } from '@/features/scanner/composables/useScanProgress'
|
||||
|
||||
type BookIdsCallback = (bookIds: number[]) => void
|
||||
type BookTransferredCallback = (event: BookTransferredEvent) => void
|
||||
|
||||
const missingCallbacks = new Set<BookIdsCallback>()
|
||||
const restoredCallbacks = new Set<BookIdsCallback>()
|
||||
const movedCallbacks = new Set<BookIdsCallback>()
|
||||
const transferredCallbacks = new Set<BookTransferredCallback>()
|
||||
|
||||
let pendingMissingCount = 0
|
||||
let pendingRestoredCount = 0
|
||||
@@ -67,25 +70,37 @@ function ensureInitialized() {
|
||||
clearTimeout(movedToastTimer ?? undefined)
|
||||
movedToastTimer = setTimeout(flushMovedToast, 1000)
|
||||
})
|
||||
|
||||
socket.on('book:transferred', (event: BookTransferredEvent) => {
|
||||
for (const cb of transferredCallbacks) cb(event)
|
||||
})
|
||||
}
|
||||
|
||||
function registerCallback<T extends (...args: never[]) => void>(callbacks: Set<T>, cb: T): () => void {
|
||||
callbacks.add(cb)
|
||||
const cleanup = () => callbacks.delete(cb)
|
||||
if (getCurrentInstance()) onUnmounted(cleanup)
|
||||
return cleanup
|
||||
}
|
||||
|
||||
export function useBookEvents() {
|
||||
ensureInitialized()
|
||||
|
||||
function onBookMissing(cb: BookIdsCallback): () => void {
|
||||
missingCallbacks.add(cb)
|
||||
return () => missingCallbacks.delete(cb)
|
||||
return registerCallback(missingCallbacks, cb)
|
||||
}
|
||||
|
||||
function onBookRestored(cb: BookIdsCallback): () => void {
|
||||
restoredCallbacks.add(cb)
|
||||
return () => restoredCallbacks.delete(cb)
|
||||
return registerCallback(restoredCallbacks, cb)
|
||||
}
|
||||
|
||||
function onBookMoved(cb: BookIdsCallback): () => void {
|
||||
movedCallbacks.add(cb)
|
||||
return () => movedCallbacks.delete(cb)
|
||||
return registerCallback(movedCallbacks, cb)
|
||||
}
|
||||
|
||||
return { onBookMissing, onBookRestored, onBookMoved }
|
||||
function onBookTransferred(cb: BookTransferredCallback): () => void {
|
||||
return registerCallback(transferredCallbacks, cb)
|
||||
}
|
||||
|
||||
return { onBookMissing, onBookRestored, onBookMoved, onBookTransferred }
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
const { onBookMissing, onBookRestored, onBookMoved } = useBookEvents()
|
||||
const { onBookMissing, onBookRestored, onBookMoved, onBookTransferred } = useBookEvents()
|
||||
onBookMissing((bookIds) => {
|
||||
if (detail.value && bookIds.includes(detail.value.id)) {
|
||||
detail.value = { ...detail.value, status: 'missing' }
|
||||
@@ -67,6 +67,9 @@ onBookRestored((bookIds) => {
|
||||
onBookMoved((bookIds) => {
|
||||
if (detail.value && bookIds.includes(detail.value.id)) fetch(detail.value.id)
|
||||
})
|
||||
onBookTransferred((event) => {
|
||||
if (detail.value && event.bookIds.includes(detail.value.id)) fetch(detail.value.id)
|
||||
})
|
||||
|
||||
watch(bookId, (id) => fetch(id), { immediate: true })
|
||||
|
||||
|
||||
@@ -305,23 +305,6 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const { onBookMissing, onBookRestored, onBookMoved } = useBookEvents()
|
||||
|
||||
function applyStatusToLoadedBooks(bookIds: number[], status: BookCard['status']) {
|
||||
const targets = new Set(bookIds)
|
||||
updateBooks(books.value.filter((book) => targets.has(book.id) && book.status !== status).map((book) => ({ ...book, status })))
|
||||
}
|
||||
|
||||
onBookMissing((bookIds) => {
|
||||
applyStatusToLoadedBooks(bookIds, 'missing')
|
||||
})
|
||||
onBookRestored((bookIds) => {
|
||||
applyStatusToLoadedBooks(bookIds, 'present')
|
||||
})
|
||||
onBookMoved((bookIds) => {
|
||||
applyStatusToLoadedBooks(bookIds, 'present')
|
||||
})
|
||||
|
||||
const filterOpen = ref(false)
|
||||
const mobileControlsExpanded = ref(false)
|
||||
|
||||
@@ -428,6 +411,50 @@ const {
|
||||
querySelection,
|
||||
})
|
||||
|
||||
const { onBookMissing, onBookRestored, onBookMoved, onBookTransferred } = useBookEvents()
|
||||
const TRANSFER_REFRESH_DEBOUNCE_MS = 300
|
||||
let transferRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function scheduleTransferRefresh(targetLibraryId: number) {
|
||||
if (transferRefreshTimer) clearTimeout(transferRefreshTimer)
|
||||
transferRefreshTimer = setTimeout(() => {
|
||||
transferRefreshTimer = null
|
||||
if (libraryId.value !== targetLibraryId) return
|
||||
resetBooks()
|
||||
refreshBuckets()
|
||||
}, TRANSFER_REFRESH_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (transferRefreshTimer) clearTimeout(transferRefreshTimer)
|
||||
})
|
||||
|
||||
function applyStatusToLoadedBooks(bookIds: number[], status: BookCard['status']) {
|
||||
const targets = new Set(bookIds)
|
||||
updateBooks(books.value.filter((book) => targets.has(book.id) && book.status !== status).map((book) => ({ ...book, status })))
|
||||
}
|
||||
|
||||
onBookMissing((bookIds) => {
|
||||
applyStatusToLoadedBooks(bookIds, 'missing')
|
||||
})
|
||||
onBookRestored((bookIds) => {
|
||||
applyStatusToLoadedBooks(bookIds, 'present')
|
||||
})
|
||||
onBookMoved((bookIds) => {
|
||||
applyStatusToLoadedBooks(bookIds, 'present')
|
||||
})
|
||||
onBookTransferred((event) => {
|
||||
if (libraryId.value === event.fromLibraryId) {
|
||||
const transferred = new Set(event.bookIds)
|
||||
books.value = books.value.filter((book) => !transferred.has(book.id))
|
||||
deselectAll(event.bookIds)
|
||||
if (querySelection.value) querySelection.value = null
|
||||
scheduleTransferRefresh(event.fromLibraryId)
|
||||
} else if (libraryId.value === event.toLibraryId) {
|
||||
scheduleTransferRefresh(event.toLibraryId)
|
||||
}
|
||||
})
|
||||
|
||||
const bookGridRef = ref<{ scrollToIndex: (index: number) => void } | null>(null)
|
||||
|
||||
watch(
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock('@/features/book/composables/useBookEvents', () => ({
|
||||
onBookMissing: () => undefined,
|
||||
onBookRestored: () => undefined,
|
||||
onBookMoved: () => undefined,
|
||||
onBookTransferred: () => undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
+4
-2
@@ -50,8 +50,10 @@ services:
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
'node -e "fetch(''http://127.0.0.1:''+(process.env.PORT||3000)+''/api/v1/health'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"',
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"const p=process.env.PORT||3000;fetch('http://127.0.0.1:'+p+'/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
@@ -37,6 +37,12 @@ export interface BookMovedEvent {
|
||||
bookIds: number[];
|
||||
}
|
||||
|
||||
export interface BookTransferredEvent {
|
||||
fromLibraryId: number;
|
||||
toLibraryId: number;
|
||||
bookIds: number[];
|
||||
}
|
||||
|
||||
export interface ScanBooksAddedEvent {
|
||||
libraryId: number;
|
||||
books: import("./book").BookCard[];
|
||||
|
||||
Generated
+3
-3
@@ -285,9 +285,6 @@ importers:
|
||||
"@nestjs/websockets":
|
||||
specifier: ^11.1.27
|
||||
version: 11.1.27(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
"@parcel/watcher":
|
||||
specifier: ^2.5.6
|
||||
version: 2.5.6
|
||||
"@types/archiver":
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
@@ -300,6 +297,9 @@ importers:
|
||||
cheerio:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
chokidar:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3
|
||||
class-transformer:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -67,11 +67,11 @@
|
||||
"@nestjs/terminus": "^11.1.1",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.27",
|
||||
"@parcel/watcher": "^2.5.6",
|
||||
"@types/archiver": "^8.0.0",
|
||||
"archiver": "~8.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cheerio": "^1.2.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
|
||||
@@ -13,12 +13,12 @@ vi.mock('fs/promises', () => ({
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@parcel/watcher', () => ({
|
||||
subscribe: vi.fn(),
|
||||
vi.mock('chokidar', () => ({
|
||||
watch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { mkdir, readdir, realpath, unlink } from 'fs/promises';
|
||||
import { subscribe } from '@parcel/watcher';
|
||||
import { watch } from 'chokidar';
|
||||
|
||||
import { isPrimaryFormat } from '../scanner/lib/classify';
|
||||
import { waitForStability } from '../scanner/lib/stability';
|
||||
@@ -43,6 +43,19 @@ function makeService(bookDockPath = '/data/book-dock') {
|
||||
return { service, ingestService, repo, gateway };
|
||||
}
|
||||
|
||||
function makeReadyWatcher(overrides: { close?: ReturnType<typeof vi.fn> } = {}) {
|
||||
const watcher = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
once: vi.fn().mockImplementation((eventName: string, handler: () => void) => {
|
||||
if (eventName === 'ready') handler();
|
||||
return watcher;
|
||||
}),
|
||||
off: vi.fn().mockReturnThis(),
|
||||
close: overrides.close ?? vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return watcher;
|
||||
}
|
||||
|
||||
describe('BookDockWatcherService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -72,18 +85,20 @@ describe('BookDockWatcherService', () => {
|
||||
|
||||
it('startWatcher ensures directory exists and subscribes for file events', async () => {
|
||||
const { service } = makeService();
|
||||
vi.mocked(subscribe).mockResolvedValue({ unsubscribe: vi.fn() } as never);
|
||||
vi.mocked(watch).mockReturnValue(makeReadyWatcher() as never);
|
||||
|
||||
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));
|
||||
expect(watch).toHaveBeenCalledWith('/data/book-dock', { ignoreInitial: true });
|
||||
});
|
||||
|
||||
it('startWatcher swallows watcher boot errors', async () => {
|
||||
const { service } = makeService();
|
||||
vi.mocked(subscribe).mockRejectedValue(new Error('watch init failed'));
|
||||
vi.mocked(watch).mockImplementation(() => {
|
||||
throw new Error('watch init failed');
|
||||
});
|
||||
|
||||
await expect((service as any).startWatcher()).resolves.toBeUndefined();
|
||||
});
|
||||
@@ -142,14 +157,14 @@ describe('BookDockWatcherService', () => {
|
||||
|
||||
it('onModuleDestroy clears timers and unsubscribes active watcher', async () => {
|
||||
const { service } = makeService();
|
||||
const unsubscribe = vi.fn().mockResolvedValue(undefined);
|
||||
(service as any).subscription = { unsubscribe };
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
(service as any).subscription = { close };
|
||||
const timer = setTimeout(() => undefined, 1_000);
|
||||
(service as any).pendingTimers.set('/tmp/file.epub', { timer, type: 'create' });
|
||||
|
||||
await service.onModuleDestroy();
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect((service as any).pendingTimers.size).toBe(0);
|
||||
expect((service as any).subscription).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { watch, type FSWatcher } from 'chokidar';
|
||||
import { Dirent } from 'fs';
|
||||
import { mkdir, readdir, realpath, unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import type { AsyncSubscription } from '@parcel/watcher';
|
||||
|
||||
import { isPrimaryFormat } from '../scanner/lib/classify';
|
||||
import { waitForStability } from '../scanner/lib/stability';
|
||||
@@ -20,7 +20,7 @@ const COVERS_DIR = 'covers';
|
||||
export class BookDockWatcherService implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private readonly logger = new Logger(BookDockWatcherService.name);
|
||||
private bookDockPath: string;
|
||||
private subscription: AsyncSubscription | null = null;
|
||||
private subscription: FSWatcher | null = null;
|
||||
private readonly pendingTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; type: EventType }>();
|
||||
|
||||
constructor(
|
||||
@@ -42,7 +42,7 @@ export class BookDockWatcherService implements OnApplicationBootstrap, OnModuleD
|
||||
for (const entry of this.pendingTimers.values()) clearTimeout(entry.timer);
|
||||
this.pendingTimers.clear();
|
||||
if (this.subscription) {
|
||||
await this.subscription.unsubscribe();
|
||||
await this.subscription.close();
|
||||
this.subscription = null;
|
||||
}
|
||||
}
|
||||
@@ -57,21 +57,27 @@ export class BookDockWatcherService implements OnApplicationBootstrap, OnModuleD
|
||||
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) => {
|
||||
if (err) {
|
||||
this.logger.warn(`Book Dock watcher error: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
for (const event of events) {
|
||||
if (event.type === 'delete' || event.type === 'create') {
|
||||
if (this.isInCoversDir(event.path)) continue;
|
||||
this.schedule(event.type, event.path);
|
||||
}
|
||||
}
|
||||
this.subscription = watch(this.bookDockPath, { ignoreInitial: true });
|
||||
this.subscription.on('all', (eventName, eventPath) => {
|
||||
const type = normalizeWatchEvent(eventName);
|
||||
if (!type || this.isInCoversDir(eventPath)) return;
|
||||
this.schedule(type, eventPath);
|
||||
});
|
||||
this.subscription.on('error', (err) => {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
this.logger.warn(`Book Dock watcher error: ${error.message}`);
|
||||
});
|
||||
await waitForWatcherReady(this.subscription);
|
||||
this.logger.log(`Watching Book Dock folder: ${this.bookDockPath}`);
|
||||
} catch (err) {
|
||||
if (this.subscription) {
|
||||
try {
|
||||
await this.subscription.close();
|
||||
} catch {
|
||||
// best-effort cleanup after a failed watcher startup
|
||||
}
|
||||
this.subscription = null;
|
||||
}
|
||||
this.logger.warn(`Failed to start Book Dock watcher: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -142,3 +148,25 @@ async function safeUnlink(path: string): Promise<void> {
|
||||
// file may already be deleted
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWatchEvent(eventName: string): EventType | null {
|
||||
if (eventName === 'unlink' || eventName === 'unlinkDir') return 'delete';
|
||||
if (eventName === 'add' || eventName === 'addDir' || eventName === 'change') return 'create';
|
||||
return null;
|
||||
}
|
||||
|
||||
function waitForWatcherReady(watcher: FSWatcher): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleReady = () => {
|
||||
watcher.off('error', handleError);
|
||||
resolve();
|
||||
};
|
||||
const handleError = (error: unknown) => {
|
||||
watcher.off('ready', handleReady);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
|
||||
watcher.once('ready', handleReady);
|
||||
watcher.once('error', handleError);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ const mockRepo: Mocked<
|
||||
| 'findLibrarySettings'
|
||||
| 'updateBookPrimaryFile'
|
||||
| 'findBookById'
|
||||
| 'findPresentDuplicateBookFilesByHash'
|
||||
| 'replaceDuplicateBookWithMovedBook'
|
||||
>
|
||||
> = {
|
||||
findBookFileByAbsolutePath: vi.fn(),
|
||||
@@ -54,6 +56,8 @@ const mockRepo: Mocked<
|
||||
findLibrarySettings: vi.fn(),
|
||||
updateBookPrimaryFile: vi.fn(),
|
||||
findBookById: vi.fn(),
|
||||
findPresentDuplicateBookFilesByHash: vi.fn(),
|
||||
replaceDuplicateBookWithMovedBook: vi.fn(),
|
||||
};
|
||||
|
||||
function makeService() {
|
||||
@@ -87,6 +91,8 @@ beforeEach(() => {
|
||||
mockRepo.findBookById.mockResolvedValue(null);
|
||||
mockRepo.updateBookFolderPath.mockResolvedValue(undefined);
|
||||
mockRepo.findLibraryFolderPath.mockResolvedValue('/books');
|
||||
mockRepo.findPresentDuplicateBookFilesByHash.mockResolvedValue([]);
|
||||
mockRepo.replaceDuplicateBookWithMovedBook.mockResolvedValue(null);
|
||||
mockReaddir.mockResolvedValue([]);
|
||||
mockRepo.updateBookPrimaryFile.mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -136,6 +142,93 @@ describe('handleUnlink', () => {
|
||||
expect(result).toEqual({ type: 'book-missing', libraryId: 5, bookIds: [12] });
|
||||
});
|
||||
|
||||
it('reconciles a delayed cross-library move when the destination duplicate already exists', async () => {
|
||||
const sourcePath = '/source/Author/Book/Book.epub';
|
||||
const destinationPath = '/dest/Author/Book/Book.epub';
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
|
||||
file: { id: 99, bookId: 12, role: 'content', fileHash: 'abc123', relPath: 'Author/Book/Book.epub', absolutePath: sourcePath },
|
||||
libraryId: 5,
|
||||
primaryFileId: 99,
|
||||
} as any);
|
||||
mockRepo.findBookFilesByBookId.mockResolvedValue([
|
||||
{
|
||||
id: 99,
|
||||
bookId: 12,
|
||||
role: 'content',
|
||||
format: 'epub',
|
||||
sizeBytes: 1000,
|
||||
fileHash: 'abc123',
|
||||
relPath: 'Author/Book/Book.epub',
|
||||
absolutePath: sourcePath,
|
||||
},
|
||||
] as any);
|
||||
mockRepo.findBookById.mockResolvedValue({ id: 12, libraryId: 5, status: 'missing', updatedAt: new Date('2024-01-01T00:01:00Z') } as any);
|
||||
mockRepo.findPresentDuplicateBookFilesByHash.mockResolvedValue([
|
||||
{
|
||||
bookId: 77,
|
||||
libraryId: 9,
|
||||
bookAddedAt: new Date('2024-01-01T00:00:30Z'),
|
||||
file: { id: 700, relPath: 'Author/Book/Book.epub', absolutePath: destinationPath },
|
||||
},
|
||||
] as any);
|
||||
mockRepo.replaceDuplicateBookWithMovedBook.mockResolvedValue({ bookId: 12, libraryId: 9, duplicateBookId: 77 });
|
||||
mockStat.mockImplementation((path) => {
|
||||
if (path === destinationPath) return Promise.resolve(makeFileStat());
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
const result = await makeService().handleUnlink(sourcePath);
|
||||
|
||||
expect(mockRepo.markBooksAsMissing).toHaveBeenCalledWith([12]);
|
||||
expect(mockRepo.replaceDuplicateBookWithMovedBook).toHaveBeenCalledWith({
|
||||
sourceBookId: 12,
|
||||
sourceFileId: 99,
|
||||
duplicateBookId: 77,
|
||||
duplicateFileId: 700,
|
||||
});
|
||||
expect(result).toEqual({ type: 'book-transferred', fromLibraryId: 5, toLibraryId: 9, bookIds: [12] });
|
||||
});
|
||||
|
||||
it('keeps delayed duplicate repair as book-moved when source and destination are in the same library', async () => {
|
||||
const sourcePath = '/books/Author/Book/Book.epub';
|
||||
const destinationPath = '/books/Author/Renamed/Book.epub';
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
|
||||
file: { id: 101, bookId: 14, role: 'content', fileHash: 'same-lib-hash', relPath: 'Author/Book/Book.epub', absolutePath: sourcePath },
|
||||
libraryId: 9,
|
||||
primaryFileId: 101,
|
||||
} as any);
|
||||
mockRepo.findBookFilesByBookId.mockResolvedValue([
|
||||
{
|
||||
id: 101,
|
||||
bookId: 14,
|
||||
role: 'content',
|
||||
format: 'epub',
|
||||
sizeBytes: 1000,
|
||||
fileHash: 'same-lib-hash',
|
||||
relPath: 'Author/Book/Book.epub',
|
||||
absolutePath: sourcePath,
|
||||
},
|
||||
] as any);
|
||||
mockRepo.findBookById.mockResolvedValue({ id: 14, libraryId: 9, status: 'missing', updatedAt: new Date('2024-01-01T00:01:00Z') } as any);
|
||||
mockRepo.findPresentDuplicateBookFilesByHash.mockResolvedValue([
|
||||
{
|
||||
bookId: 88,
|
||||
libraryId: 9,
|
||||
bookAddedAt: new Date('2024-01-01T00:00:30Z'),
|
||||
file: { id: 701, relPath: 'Author/Book/Book.epub', absolutePath: destinationPath },
|
||||
},
|
||||
] as any);
|
||||
mockRepo.replaceDuplicateBookWithMovedBook.mockResolvedValue({ bookId: 14, libraryId: 9, duplicateBookId: 88 });
|
||||
mockStat.mockImplementation((path) => {
|
||||
if (path === destinationPath) return Promise.resolve(makeFileStat());
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
const result = await makeService().handleUnlink(sourcePath);
|
||||
|
||||
expect(result).toEqual({ type: 'book-moved', libraryId: 9, bookIds: [14] });
|
||||
});
|
||||
|
||||
it('promotes highest-priority remaining file when primary is deleted', async () => {
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
|
||||
file: { id: 99, bookId: 12, role: 'content' },
|
||||
@@ -567,6 +660,26 @@ describe('handleCreate — move detection', () => {
|
||||
expect(result).toEqual({ type: 'book-moved', libraryId: 3, bookIds: [40] });
|
||||
});
|
||||
|
||||
it('keeps folderPath equal to the moved file path in book_per_file mode', async () => {
|
||||
const fileStat = makeFileStat({ ino: 5001 });
|
||||
mockStat.mockResolvedValueOnce(fileStat).mockRejectedValueOnce(new Error('ENOENT'));
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
|
||||
mockRepo.findMissingBookByFolderPath.mockResolvedValue(null);
|
||||
mockRepo.findLibrarySettings.mockResolvedValue({ organizationMode: 'book_per_file', formatPriority: ['epub'] } as any);
|
||||
mockRepo.findBookFileWithContextByIno.mockResolvedValue({
|
||||
file: { id: 61, bookId: 41, absolutePath: '/books/old.epub' },
|
||||
libraryId: 3,
|
||||
folderPath: '/books/old.epub',
|
||||
libraryFolderPath: '/books',
|
||||
bookStatus: 'missing',
|
||||
} as any);
|
||||
|
||||
const result = await makeService().handleCreate('/books/Moved/renamed.epub');
|
||||
|
||||
expect(mockRepo.updateBookFolderPath).toHaveBeenCalledWith(41, '/books/Moved/renamed.epub');
|
||||
expect(result).toEqual({ type: 'book-moved', libraryId: 3, bookIds: [41] });
|
||||
});
|
||||
|
||||
it('skips inode-based move detection when inode is precision-unsafe in JavaScript', async () => {
|
||||
const fileStat = makeFileStat({ ino: 651896050678335552n });
|
||||
mockStat.mockResolvedValue(fileStat);
|
||||
@@ -655,4 +768,47 @@ describe('reconcileMissingBooks', () => {
|
||||
expect(mockRepo.markBooksAsPresent).not.toHaveBeenCalled();
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('repairs missing books that already have a recent destination duplicate', async () => {
|
||||
const sourcePath = '/source/Author/Book/Book.epub';
|
||||
const destinationPath = '/dest/Author/Book/Book.epub';
|
||||
mockRepo.findMissingBooksForLibraries.mockResolvedValue([
|
||||
{ id: 52, libraryId: 2, libraryFolderId: 10, folderPath: '/source/Author/Book' } as any,
|
||||
]);
|
||||
mockRepo.findBookById.mockResolvedValue({ id: 52, libraryId: 2, status: 'missing', updatedAt: new Date('2024-01-01T00:02:00Z') } as any);
|
||||
mockRepo.findBookFilesByBookId.mockResolvedValue([
|
||||
{
|
||||
id: 302,
|
||||
bookId: 52,
|
||||
role: 'content',
|
||||
absolutePath: sourcePath,
|
||||
relPath: 'Author/Book/Book.epub',
|
||||
fileHash: 'abc123',
|
||||
},
|
||||
] as any);
|
||||
mockRepo.findPresentDuplicateBookFilesByHash.mockResolvedValue([
|
||||
{
|
||||
bookId: 80,
|
||||
libraryId: 7,
|
||||
bookAddedAt: new Date('2024-01-01T00:01:30Z'),
|
||||
file: { id: 801, relPath: 'Author/Book/Book.epub', absolutePath: destinationPath },
|
||||
},
|
||||
] as any);
|
||||
mockRepo.replaceDuplicateBookWithMovedBook.mockResolvedValue({ bookId: 52, libraryId: 7, duplicateBookId: 80 });
|
||||
mockStat.mockImplementation((path) => {
|
||||
if (path === destinationPath) return Promise.resolve(makeFileStat());
|
||||
return Promise.reject(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
const results = await makeService().reconcileMissingBooks([2]);
|
||||
|
||||
expect(mockRepo.replaceDuplicateBookWithMovedBook).toHaveBeenCalledWith({
|
||||
sourceBookId: 52,
|
||||
sourceFileId: 302,
|
||||
duplicateBookId: 80,
|
||||
duplicateFileId: 801,
|
||||
});
|
||||
expect(mockRepo.markBooksAsPresent).not.toHaveBeenCalled();
|
||||
expect(results).toEqual([{ type: 'book-transferred', fromLibraryId: 2, toLibraryId: 7, bookIds: [52] }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,10 +12,13 @@ export type FileEventResult =
|
||||
| { type: 'book-missing'; libraryId: number; bookIds: number[] }
|
||||
| { type: 'book-restored'; libraryId: number; bookIds: number[] }
|
||||
| { type: 'book-moved'; libraryId: number; bookIds: number[] }
|
||||
| { type: 'book-transferred'; fromLibraryId: number; toLibraryId: number; bookIds: number[] }
|
||||
| { type: 'noop' };
|
||||
|
||||
type FsStat = BigIntStats;
|
||||
|
||||
const DUPLICATE_MOVE_REPAIR_WINDOW_MS = 30 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class FileEventProcessorService {
|
||||
private readonly logger = new Logger(FileEventProcessorService.name);
|
||||
@@ -51,6 +54,8 @@ export class FileEventProcessorService {
|
||||
// If the file was truly deleted (not renamed), the next full scan will prune it.
|
||||
await this.scannerRepo.updateBookPrimaryFile(file.bookId, null);
|
||||
await this.scannerRepo.markBooksAsMissing([file.bookId]);
|
||||
const moved = await this.tryResolveDuplicateMove(file.bookId, file.id);
|
||||
if (moved) return moved;
|
||||
this.logger.log(
|
||||
`[scanner.file_event.unlink] [end] libraryId=${rowLibraryId} bookId=${file.bookId} path="${sanitizeLogValue(absolutePath)}" action=mark_missing_selected_removed - selected content file removed`,
|
||||
);
|
||||
@@ -207,10 +212,9 @@ export class FileEventProcessorService {
|
||||
});
|
||||
}
|
||||
const cached = settingsCache.get(book.libraryId)!;
|
||||
const result = await this.tryRestoreBook(
|
||||
book as { id: number; libraryId: number; libraryFolderId: number; folderPath: string },
|
||||
cached.formatPriority,
|
||||
);
|
||||
const result =
|
||||
(await this.tryResolveDuplicateMove(book.id)) ??
|
||||
(await this.tryRestoreBook(book as { id: number; libraryId: number; libraryFolderId: number; folderPath: string }, cached.formatPriority));
|
||||
if (result.type !== 'noop') results.push(result);
|
||||
}
|
||||
|
||||
@@ -262,6 +266,54 @@ export class FileEventProcessorService {
|
||||
return { type: 'book-restored', libraryId: book.libraryId, bookIds: [book.id] };
|
||||
}
|
||||
|
||||
private async tryResolveDuplicateMove(bookId: number, preferredFileId?: number): Promise<FileEventResult | null> {
|
||||
const sourceBook = await this.scannerRepo.findBookById(bookId);
|
||||
if (!sourceBook || sourceBook.status !== 'missing') return null;
|
||||
|
||||
const sourceUpdatedAt = this.toTimestamp(sourceBook.updatedAt);
|
||||
if (!sourceUpdatedAt) return null;
|
||||
|
||||
const sourceFiles = (await this.scannerRepo.findBookFilesByBookId(bookId))
|
||||
.filter((file) => file.role === 'content' && file.fileHash && file.relPath)
|
||||
.sort((a, b) => Number(b.id === preferredFileId) - Number(a.id === preferredFileId));
|
||||
|
||||
for (const sourceFile of sourceFiles) {
|
||||
const sourceExists = await this.pathIsFile(sourceFile.absolutePath);
|
||||
if (sourceExists) continue;
|
||||
|
||||
const candidates = await this.scannerRepo.findPresentDuplicateBookFilesByHash(sourceFile.fileHash!, bookId);
|
||||
const viable: typeof candidates = [];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.file.relPath !== sourceFile.relPath) continue;
|
||||
if (candidate.file.absolutePath === sourceFile.absolutePath) continue;
|
||||
if (!this.isWithinMoveWindow(sourceUpdatedAt, candidate.bookAddedAt)) continue;
|
||||
if (!(await this.pathIsFile(candidate.file.absolutePath))) continue;
|
||||
viable.push(candidate);
|
||||
}
|
||||
|
||||
if (viable.length !== 1) continue;
|
||||
|
||||
const candidate = viable[0]!;
|
||||
const moved = await this.scannerRepo.replaceDuplicateBookWithMovedBook({
|
||||
sourceBookId: bookId,
|
||||
sourceFileId: sourceFile.id,
|
||||
duplicateBookId: candidate.bookId,
|
||||
duplicateFileId: candidate.file.id,
|
||||
});
|
||||
if (!moved) continue;
|
||||
|
||||
this.logger.log(
|
||||
`[scanner.file_event.duplicate_move] [end] libraryId=${moved.libraryId} bookId=${bookId} duplicateBookId=${moved.duplicateBookId} from="${sanitizeLogValue(sourceFile.absolutePath)}" to="${sanitizeLogValue(candidate.file.absolutePath)}" - duplicate import reconciled as moved book`,
|
||||
);
|
||||
if (sourceBook.libraryId !== moved.libraryId) {
|
||||
return { type: 'book-transferred', fromLibraryId: sourceBook.libraryId, toLibraryId: moved.libraryId, bookIds: [bookId] };
|
||||
}
|
||||
return { type: 'book-moved', libraryId: moved.libraryId, bookIds: [bookId] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async detectMovedFile(newAbsolutePath: string, fileStat: FsStat, scopeLibraryId?: number): Promise<FileEventResult> {
|
||||
const ino = clampIno(fileStat.ino);
|
||||
if (ino === 0) return { type: 'noop' };
|
||||
@@ -285,7 +337,13 @@ export class FileEventProcessorService {
|
||||
return { type: 'noop' };
|
||||
}
|
||||
|
||||
const newFolderPath = dirname(newAbsolutePath) === libraryFolderPath ? newAbsolutePath : dirname(newAbsolutePath);
|
||||
const settings = await this.scannerRepo.findLibrarySettings(rowLibraryId);
|
||||
const newFolderPath =
|
||||
settings?.organizationMode === 'book_per_file'
|
||||
? newAbsolutePath
|
||||
: dirname(newAbsolutePath) === libraryFolderPath
|
||||
? newAbsolutePath
|
||||
: dirname(newAbsolutePath);
|
||||
if (newFolderPath !== oldFolderPath) {
|
||||
const targetBooks = await this.scannerRepo.findBooksByFolderPath(newFolderPath, rowLibraryId);
|
||||
const hasFolderCollision = targetBooks.some((book) => book.id !== file.bookId && book.folderPath === newFolderPath);
|
||||
@@ -318,6 +376,25 @@ export class FileEventProcessorService {
|
||||
return { ino: clampIno(s.ino), sizeBytes: Number(s.size), mtime: s.mtime };
|
||||
}
|
||||
|
||||
private async pathIsFile(path: string): Promise<boolean> {
|
||||
try {
|
||||
const s = await stat(path, { bigint: true });
|
||||
return s.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private isWithinMoveWindow(sourceUpdatedAt: number, candidateAddedAt: Date): boolean {
|
||||
const candidateTime = this.toTimestamp(candidateAddedAt);
|
||||
return candidateTime != null && Math.abs(sourceUpdatedAt - candidateTime) <= DUPLICATE_MOVE_REPAIR_WINDOW_MS;
|
||||
}
|
||||
|
||||
private toTimestamp(value: Date | string): number | null {
|
||||
const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
private async detectMovedBooksInDir(dirPath: string, scopeLibraryId?: number): Promise<FileEventResult> {
|
||||
const entries = await readdir(dirPath, { recursive: true, withFileTypes: true }).catch(() => []);
|
||||
const movedBookIds: number[] = [];
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
vi.mock('@parcel/watcher', () => ({
|
||||
subscribe: vi.fn(),
|
||||
vi.mock('chokidar', () => ({
|
||||
watch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { MockedFunction } from 'vitest';
|
||||
import { subscribe } from '@parcel/watcher';
|
||||
import { watch } from 'chokidar';
|
||||
|
||||
import { FileEventProcessorService } from './file-event-processor.service';
|
||||
import { FileWatcherService } from './file-watcher.service';
|
||||
import { ScanGateway } from './scan.gateway';
|
||||
import { ScannerService } from './scanner.service';
|
||||
|
||||
const mockSubscribe = subscribe as MockedFunction<typeof subscribe>;
|
||||
const mockWatch = watch as MockedFunction<typeof watch>;
|
||||
|
||||
function makeService(db: any = {}) {
|
||||
const processor = {
|
||||
@@ -25,6 +25,7 @@ function makeService(db: any = {}) {
|
||||
emitBookMissing: vi.fn(),
|
||||
emitBookRestored: vi.fn(),
|
||||
emitBookMoved: vi.fn(),
|
||||
emitBookTransferred: vi.fn(),
|
||||
} as unknown as ScanGateway;
|
||||
|
||||
const scannerService = {
|
||||
@@ -32,6 +33,7 @@ function makeService(db: any = {}) {
|
||||
scanBookFolderAsync: vi.fn(),
|
||||
scanBookDirectoryAsync: vi.fn(),
|
||||
isScanRunning: vi.fn().mockReturnValue(false),
|
||||
bufferBookMissingEvent: vi.fn(),
|
||||
bufferBooksUnavailableNotification: vi.fn(),
|
||||
bufferBooksRestoredNotification: vi.fn(),
|
||||
} as unknown as ScannerService;
|
||||
@@ -59,6 +61,19 @@ function makeBootstrapDb(watchedLibraries: Array<{ id: number }> = []): any {
|
||||
return { select };
|
||||
}
|
||||
|
||||
function makeReadyWatcher(overrides: { close?: ReturnType<typeof vi.fn> } = {}) {
|
||||
const watcher = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
once: vi.fn().mockImplementation((eventName: string, handler: () => void) => {
|
||||
if (eventName === 'ready') handler();
|
||||
return watcher;
|
||||
}),
|
||||
off: vi.fn().mockReturnThis(),
|
||||
close: overrides.close ?? vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return watcher;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
@@ -66,7 +81,7 @@ beforeEach(() => {
|
||||
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
mockSubscribe.mockReset();
|
||||
mockWatch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -100,44 +115,47 @@ describe('FileWatcherService lifecycle and watcher controls', () => {
|
||||
const { service } = makeService();
|
||||
|
||||
await expect(service.startWatcher(7, [])).resolves.toBeUndefined();
|
||||
expect(mockSubscribe).not.toHaveBeenCalled();
|
||||
expect(mockWatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('subscribes to paths and normalizes create/update/delete events', async () => {
|
||||
const unsubscribe = vi.fn().mockResolvedValue(undefined);
|
||||
const callbacks: Array<(error: Error | null, events: Array<{ type: string; path: string }>) => void> = [];
|
||||
mockSubscribe.mockImplementation((_path, cb) => {
|
||||
callbacks.push(cb as any);
|
||||
return Promise.resolve({ unsubscribe } as any);
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const allHandlers: Array<(eventName: string, path: string) => void> = [];
|
||||
mockWatch.mockImplementation(() => {
|
||||
const watcher = makeReadyWatcher({ close });
|
||||
watcher.on.mockImplementation((eventName: string, handler: (...args: any[]) => void) => {
|
||||
if (eventName === 'all') allHandlers.push(handler as (eventName: string, path: string) => void);
|
||||
return watcher;
|
||||
});
|
||||
return watcher as any;
|
||||
});
|
||||
|
||||
const { service } = makeService();
|
||||
const scheduleSpy = vi.spyOn(service as any, 'schedule').mockImplementation(() => undefined);
|
||||
|
||||
await service.startWatcher(2, ['/books']);
|
||||
callbacks[0](new Error('watch callback failure'), []);
|
||||
callbacks[0](null, [
|
||||
{ type: 'create', path: '/books/new.epub' },
|
||||
{ type: 'update', path: '/books/edit.epub' },
|
||||
{ type: 'delete', path: '/books/gone.epub' },
|
||||
{ type: 'bogus', path: '/books/ignored.epub' },
|
||||
]);
|
||||
allHandlers[0]('add', '/books/new.epub');
|
||||
allHandlers[0]('change', '/books/edit.epub');
|
||||
allHandlers[0]('unlink', '/books/gone.epub');
|
||||
allHandlers[0]('raw', '/books/ignored.epub');
|
||||
|
||||
expect(scheduleSpy).toHaveBeenNthCalledWith(1, 'create', '/books/new.epub', 2);
|
||||
expect(scheduleSpy).toHaveBeenNthCalledWith(2, 'create', '/books/edit.epub', 2);
|
||||
expect(scheduleSpy).toHaveBeenNthCalledWith(3, 'delete', '/books/gone.epub', 2);
|
||||
expect(scheduleSpy).toHaveBeenCalledTimes(3);
|
||||
expect(unsubscribe).not.toHaveBeenCalled();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cleans up partially started watchers when one path subscription fails', async () => {
|
||||
const firstUnsubscribe = vi.fn().mockResolvedValue(undefined);
|
||||
mockSubscribe.mockResolvedValueOnce({ unsubscribe: firstUnsubscribe } as any).mockRejectedValueOnce(new Error('permission denied'));
|
||||
const firstClose = vi.fn().mockResolvedValue(undefined);
|
||||
mockWatch.mockReturnValueOnce(makeReadyWatcher({ close: firstClose }) as any).mockImplementationOnce(() => {
|
||||
throw new Error('permission denied');
|
||||
});
|
||||
|
||||
const { service } = makeService();
|
||||
|
||||
await expect(service.startWatcher(3, ['/books/ok', '/books/fail'])).rejects.toThrow('permission denied');
|
||||
expect(firstUnsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(firstClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns immediately when stopping a library without an active watcher', async () => {
|
||||
@@ -148,8 +166,8 @@ describe('FileWatcherService lifecycle and watcher controls', () => {
|
||||
|
||||
it('stops active watchers and clears pending timers scoped to the library', async () => {
|
||||
const { service } = makeService();
|
||||
const unsubscribe = vi.fn().mockResolvedValue(undefined);
|
||||
(service as any).subscriptions.set(11, [{ unsubscribe }]);
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
(service as any).subscriptions.set(11, [{ close }]);
|
||||
|
||||
const timerA = setTimeout(() => undefined, 1000);
|
||||
const timerB = setTimeout(() => undefined, 1000);
|
||||
@@ -176,7 +194,7 @@ describe('FileWatcherService lifecycle and watcher controls', () => {
|
||||
|
||||
await service.stopWatcher(11);
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect((service as any).pendingTimers.has('/books/a')).toBe(false);
|
||||
expect((service as any).pendingTimers.has('/books/b')).toBe(true);
|
||||
expect((service as any).pendingFolderScanTimers.has('/books/a')).toBe(false);
|
||||
@@ -190,27 +208,27 @@ describe('FileWatcherService lifecycle and watcher controls', () => {
|
||||
expect((service as any).suppressedDirScanTimers.has('12:/books/other')).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when stopWatcher cannot unsubscribe from an active subscription', async () => {
|
||||
it('throws when stopWatcher cannot close an active subscription', async () => {
|
||||
const { service } = makeService();
|
||||
(service as any).subscriptions.set(12, [
|
||||
{
|
||||
unsubscribe: vi.fn().mockRejectedValue(new Error('unsubscribe failed')),
|
||||
close: vi.fn().mockRejectedValue(new Error('close failed')),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(service.stopWatcher(12)).rejects.toThrow('unsubscribe failed');
|
||||
await expect(service.stopWatcher(12)).rejects.toThrow('close failed');
|
||||
});
|
||||
|
||||
it('clears all subscriptions during module destroy and propagates unsubscribe failures', async () => {
|
||||
it('clears all subscriptions during module destroy and propagates close failures', async () => {
|
||||
const { service } = makeService();
|
||||
const ok = vi.fn().mockResolvedValue(undefined);
|
||||
const bad = vi.fn().mockRejectedValue(new Error('destroy failed'));
|
||||
(service as any).subscriptions.set(1, [{ unsubscribe: ok }]);
|
||||
(service as any).subscriptions.set(1, [{ close: ok }]);
|
||||
|
||||
await expect(service.onModuleDestroy()).resolves.toBeUndefined();
|
||||
expect(ok).toHaveBeenCalledTimes(1);
|
||||
|
||||
(service as any).subscriptions.set(2, [{ unsubscribe: bad }]);
|
||||
(service as any).subscriptions.set(2, [{ close: bad }]);
|
||||
await expect(service.onModuleDestroy()).rejects.toThrow('destroy failed');
|
||||
});
|
||||
|
||||
@@ -222,17 +240,17 @@ describe('FileWatcherService lifecycle and watcher controls', () => {
|
||||
await expect((service as any).reconcile()).rejects.toThrow('broken reconcile');
|
||||
});
|
||||
|
||||
it('debounces cross-library reconciles and skips the source library', () => {
|
||||
it('debounces cross-library reconciles across all watched libraries', () => {
|
||||
const { service, processor } = makeService();
|
||||
(service as any).subscriptions.set(1, []);
|
||||
(service as any).subscriptions.set(2, []);
|
||||
(service as any).subscriptions.set(3, []);
|
||||
|
||||
(service as any).scheduleCrossLibraryReconcile(1);
|
||||
(service as any).scheduleCrossLibraryReconcile(1);
|
||||
(service as any).scheduleCrossLibraryReconcile();
|
||||
(service as any).scheduleCrossLibraryReconcile();
|
||||
|
||||
vi.advanceTimersByTime(1_500);
|
||||
expect(processor.reconcileMissingBooks).toHaveBeenCalledWith([2, 3]);
|
||||
expect(processor.reconcileMissingBooks).toHaveBeenCalledWith([1, 2, 3]);
|
||||
expect(processor.reconcileMissingBooks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ function makeService(db: any = {}) {
|
||||
emitBookMissing: vi.fn(),
|
||||
emitBookRestored: vi.fn(),
|
||||
emitBookMoved: vi.fn(),
|
||||
emitBookTransferred: vi.fn(),
|
||||
} as unknown as ScanGateway;
|
||||
|
||||
const scannerService = {
|
||||
@@ -27,6 +28,7 @@ function makeService(db: any = {}) {
|
||||
scanBookDirectoryAsync: vi.fn(),
|
||||
isScanRunning: vi.fn().mockReturnValue(false),
|
||||
bufferWatcherNotification: vi.fn(),
|
||||
bufferBookMissingEvent: vi.fn(),
|
||||
bufferBooksUnavailableNotification: vi.fn(),
|
||||
bufferBooksRestoredNotification: vi.fn(),
|
||||
} as unknown as ScannerService;
|
||||
@@ -61,14 +63,15 @@ describe('onApplicationBootstrap()', () => {
|
||||
// ── process() routing ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('process()', () => {
|
||||
it('emits book-missing when handleUnlink returns book-missing', async () => {
|
||||
it('buffers book-missing when handleUnlink returns book-missing', async () => {
|
||||
const { service, processor, gateway, scannerService } = makeService();
|
||||
const missing = { type: 'book-missing', libraryId: 1, bookIds: [10, 11] };
|
||||
processor.handleUnlink = vi.fn().mockResolvedValue(missing);
|
||||
|
||||
await (service as any).process('delete', '/books/Author/book.epub', 1);
|
||||
|
||||
expect(gateway.emitBookMissing).toHaveBeenCalledWith({ libraryId: 1, bookIds: [10, 11] });
|
||||
expect(gateway.emitBookMissing).not.toHaveBeenCalled();
|
||||
expect((scannerService as any).bufferBookMissingEvent).toHaveBeenCalledWith(1, [10, 11]);
|
||||
expect((scannerService as any).bufferBooksUnavailableNotification).toHaveBeenCalledWith(1, [10, 11]);
|
||||
});
|
||||
|
||||
@@ -82,7 +85,8 @@ describe('process()', () => {
|
||||
|
||||
expect(processor.handleUnlink).toHaveBeenCalledWith('/books/Author', 3);
|
||||
expect(processor.handleUnlinkDir).toHaveBeenCalledWith('/books/Author', 3);
|
||||
expect(gateway.emitBookMissing).toHaveBeenCalledWith({ libraryId: 3, bookIds: [20] });
|
||||
expect(gateway.emitBookMissing).not.toHaveBeenCalled();
|
||||
expect((scannerService as any).bufferBookMissingEvent).toHaveBeenCalledWith(3, [20]);
|
||||
expect((scannerService as any).bufferBooksUnavailableNotification).toHaveBeenCalledWith(3, [20]);
|
||||
});
|
||||
|
||||
@@ -153,6 +157,18 @@ describe('process()', () => {
|
||||
expect(scannerService.startScanAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits book-transferred when an event processor result crosses libraries', async () => {
|
||||
const { service, processor, gateway, scannerService } = makeService();
|
||||
processor.handleUnlink = vi.fn().mockResolvedValue({ type: 'book-transferred', fromLibraryId: 1, toLibraryId: 2, bookIds: [5] });
|
||||
|
||||
await (service as any).process('delete', '/books/moved.epub', 1);
|
||||
|
||||
expect((gateway as any).emitBookTransferred).toHaveBeenCalledWith({ fromLibraryId: 1, toLibraryId: 2, bookIds: [5] });
|
||||
expect((gateway as any).emitBookMoved).not.toHaveBeenCalled();
|
||||
expect((scannerService as any).bufferBookMissingEvent).not.toHaveBeenCalled();
|
||||
expect((scannerService as any).bufferBooksUnavailableNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits nothing when both handlers return noop', async () => {
|
||||
const { service, gateway } = makeService();
|
||||
|
||||
@@ -232,19 +248,36 @@ describe('reconcile()', () => {
|
||||
expect((scannerService as any).bufferBooksRestoredNotification).toHaveBeenCalledWith(2, [20]);
|
||||
});
|
||||
|
||||
it('emits book-missing and book-moved for each result from reconcileMissingBooks', async () => {
|
||||
it('buffers book-missing and emits move or transfer results from reconcileMissingBooks', async () => {
|
||||
const { service, processor, gateway, scannerService } = makeService();
|
||||
(service as any).subscriptions.set(1, []);
|
||||
(processor.reconcileMissingBooks as vi.Mock).mockResolvedValue([
|
||||
{ type: 'book-missing', libraryId: 1, bookIds: [20] },
|
||||
{ type: 'book-moved', libraryId: 1, bookIds: [30] },
|
||||
{ type: 'book-transferred', fromLibraryId: 1, toLibraryId: 2, bookIds: [40] },
|
||||
]);
|
||||
|
||||
await (service as any).reconcile();
|
||||
|
||||
expect(gateway.emitBookMissing).toHaveBeenCalledWith({ libraryId: 1, bookIds: [20] });
|
||||
expect(gateway.emitBookMissing).not.toHaveBeenCalled();
|
||||
expect((scannerService as any).bufferBookMissingEvent).toHaveBeenCalledWith(1, [20]);
|
||||
expect((scannerService as any).bufferBooksUnavailableNotification).toHaveBeenCalledWith(1, [20]);
|
||||
expect(gateway.emitBookMoved).toHaveBeenCalledWith({ libraryId: 1, bookIds: [30] });
|
||||
expect((gateway as any).emitBookTransferred).toHaveBeenCalledWith({ fromLibraryId: 1, toLibraryId: 2, bookIds: [40] });
|
||||
});
|
||||
|
||||
it('emits book-transferred from cross-library reconcile results', async () => {
|
||||
const { service, processor, gateway } = makeService();
|
||||
(service as any).subscriptions.set(1, []);
|
||||
(service as any).subscriptions.set(2, []);
|
||||
(processor.reconcileMissingBooks as vi.Mock).mockResolvedValue([{ type: 'book-transferred', fromLibraryId: 1, toLibraryId: 2, bookIds: [50] }]);
|
||||
|
||||
(service as any).scheduleCrossLibraryReconcile();
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(processor.reconcileMissingBooks).toHaveBeenCalledWith([1, 2]);
|
||||
expect((gateway as any).emitBookTransferred).toHaveBeenCalledWith({ fromLibraryId: 1, toLibraryId: 2, bookIds: [50] });
|
||||
expect(gateway.emitBookMoved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when reconcileMissingBooks returns empty', async () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Inject, Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
|
||||
import { watch, type FSWatcher } from 'chokidar';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { stat } from 'fs/promises';
|
||||
import { dirname, sep } from 'path';
|
||||
import type { AsyncSubscription } from '@parcel/watcher';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
|
||||
import { DB } from '../../db';
|
||||
@@ -27,7 +27,7 @@ const RECONCILE_MS = 30 * 60 * 1_000;
|
||||
@Injectable()
|
||||
export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private readonly logger = new Logger(FileWatcherService.name);
|
||||
private readonly subscriptions = new Map<number, AsyncSubscription[]>();
|
||||
private readonly subscriptions = new Map<number, FSWatcher[]>();
|
||||
private readonly watchedLibraryPaths = new Map<number, string[]>();
|
||||
private readonly pendingTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; type: EventType; libraryId: number }>();
|
||||
private readonly pendingFolderScanTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; libraryId: number }>();
|
||||
@@ -105,7 +105,7 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
|
||||
this.suppressedDirScanTimers.clear();
|
||||
this.suppressedDirScans.clear();
|
||||
for (const subs of this.subscriptions.values()) {
|
||||
for (const sub of subs) await sub.unsubscribe();
|
||||
for (const sub of subs) await sub.close();
|
||||
}
|
||||
this.subscriptions.clear();
|
||||
this.watchedLibraryPaths.clear();
|
||||
@@ -132,13 +132,15 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
|
||||
const results = await this.processor.reconcileMissingBooks(libraryIds);
|
||||
for (const result of results) {
|
||||
if (result.type === 'book-missing') {
|
||||
this.gateway.emitBookMissing({ libraryId: result.libraryId, bookIds: result.bookIds });
|
||||
this.scannerService.bufferBookMissingEvent(result.libraryId, result.bookIds);
|
||||
this.scannerService.bufferBooksUnavailableNotification(result.libraryId, result.bookIds);
|
||||
} else if (result.type === 'book-restored') {
|
||||
this.gateway.emitBookRestored({ libraryId: result.libraryId, bookIds: result.bookIds });
|
||||
this.scannerService.bufferBooksRestoredNotification(result.libraryId, result.bookIds);
|
||||
} else if (result.type === 'book-moved') {
|
||||
this.gateway.emitBookMoved({ libraryId: result.libraryId, bookIds: result.bookIds });
|
||||
} else if (result.type === 'book-transferred') {
|
||||
this.gateway.emitBookTransferred({ fromLibraryId: result.fromLibraryId, toLibraryId: result.toLibraryId, bookIds: result.bookIds });
|
||||
}
|
||||
}
|
||||
this.logger.log(
|
||||
@@ -158,7 +160,7 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
|
||||
const event = 'scanner.watcher.start';
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${event}] [start] libraryId=${libraryId} pathCount=${paths.length} - watcher start requested`);
|
||||
const subs: AsyncSubscription[] = [];
|
||||
const subs: FSWatcher[] = [];
|
||||
let currentPath: string | null = null;
|
||||
try {
|
||||
await this.stopWatcher(libraryId);
|
||||
@@ -168,24 +170,31 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
|
||||
return;
|
||||
}
|
||||
|
||||
const { subscribe } = await import('@parcel/watcher');
|
||||
|
||||
for (const path of paths) {
|
||||
currentPath = path;
|
||||
const sub = await subscribe(path, (err, events) => {
|
||||
if (err) {
|
||||
const sub = watch(path, { ignoreInitial: true });
|
||||
sub.on('all', (eventName, eventPath) => {
|
||||
const normalizedType = normalizeWatchEvent(eventName);
|
||||
if (!normalizedType) return;
|
||||
this.schedule(normalizedType, eventPath, libraryId);
|
||||
});
|
||||
sub.on('error', (err) => {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
this.logger.warn(
|
||||
`[${event}] [fail] libraryId=${libraryId} path="${sanitizeLogValue(path)}" errorClass=${error.name} error="${sanitizeLogValue(error.message)}" - watcher callback error`,
|
||||
);
|
||||
});
|
||||
try {
|
||||
await waitForWatcherReady(sub);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
this.logger.warn(
|
||||
`[${event}] [fail] libraryId=${libraryId} path="${sanitizeLogValue(path)}" errorClass=${err.name ?? 'Error'} error="${sanitizeLogValue(err.message)}" - watcher callback error`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const fsEvent of events) {
|
||||
const normalizedType: EventType | null =
|
||||
fsEvent.type === 'delete' ? 'delete' : fsEvent.type === 'create' || fsEvent.type === 'update' ? 'create' : null;
|
||||
if (!normalizedType) continue;
|
||||
this.schedule(normalizedType, fsEvent.path, libraryId);
|
||||
}
|
||||
});
|
||||
await sub.close();
|
||||
throw err;
|
||||
}
|
||||
subs.push(sub);
|
||||
}
|
||||
|
||||
@@ -198,7 +207,7 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
|
||||
} catch (err) {
|
||||
for (const sub of subs) {
|
||||
try {
|
||||
await sub.unsubscribe();
|
||||
await sub.close();
|
||||
} catch {
|
||||
// best-effort cleanup after a partial watcher startup
|
||||
}
|
||||
@@ -224,7 +233,7 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
|
||||
this.logger.log(`[${event}] [end] libraryId=${libraryId} durationMs=${Date.now() - startedAt} hadWatcher=false - watcher stop completed`);
|
||||
return;
|
||||
}
|
||||
for (const sub of existing) await sub.unsubscribe();
|
||||
for (const sub of existing) await sub.close();
|
||||
this.subscriptions.delete(libraryId);
|
||||
this.clearPendingTimersForLibrary(libraryId);
|
||||
this.pendingCrossLibraryReconcileLibraryIds.delete(libraryId);
|
||||
@@ -273,12 +282,12 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
|
||||
}
|
||||
}
|
||||
|
||||
// Only reconcile missing books in other libraries instead of full rescan
|
||||
private scheduleCrossLibraryReconcile(sourceLibraryId: number): void {
|
||||
const otherLibraryIds = [...this.subscriptions.keys()].filter((id) => id !== sourceLibraryId);
|
||||
if (otherLibraryIds.length === 0) return;
|
||||
// Reconcile watched libraries instead of running a full rescan.
|
||||
private scheduleCrossLibraryReconcile(): void {
|
||||
const watchedLibraryIds = [...this.subscriptions.keys()];
|
||||
if (watchedLibraryIds.length === 0) return;
|
||||
|
||||
for (const libraryId of otherLibraryIds) this.pendingCrossLibraryReconcileLibraryIds.add(libraryId);
|
||||
for (const libraryId of watchedLibraryIds) this.pendingCrossLibraryReconcileLibraryIds.add(libraryId);
|
||||
if (this.pendingCrossLibraryReconcileTimer) clearTimeout(this.pendingCrossLibraryReconcileTimer);
|
||||
|
||||
this.pendingCrossLibraryReconcileTimer = setTimeout(() => {
|
||||
@@ -296,6 +305,8 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
|
||||
this.scannerService.bufferBooksRestoredNotification(result.libraryId, result.bookIds);
|
||||
} else if (result.type === 'book-moved') {
|
||||
this.gateway.emitBookMoved({ libraryId: result.libraryId, bookIds: result.bookIds });
|
||||
} else if (result.type === 'book-transferred') {
|
||||
this.gateway.emitBookTransferred({ fromLibraryId: result.fromLibraryId, toLibraryId: result.toLibraryId, bookIds: result.bookIds });
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -405,9 +416,9 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
|
||||
}
|
||||
|
||||
if (result.type === 'book-missing') {
|
||||
this.gateway.emitBookMissing({ libraryId: result.libraryId, bookIds: result.bookIds });
|
||||
this.scannerService.bufferBookMissingEvent(result.libraryId, result.bookIds);
|
||||
this.scannerService.bufferBooksUnavailableNotification(result.libraryId, result.bookIds);
|
||||
if (type === 'delete') this.scheduleCrossLibraryReconcile(result.libraryId);
|
||||
if (type === 'delete') this.scheduleCrossLibraryReconcile();
|
||||
} else if (result.type === 'book-restored') {
|
||||
this.gateway.emitBookRestored({ libraryId: result.libraryId, bookIds: result.bookIds });
|
||||
this.scannerService.bufferBooksRestoredNotification(result.libraryId, result.bookIds);
|
||||
@@ -416,6 +427,30 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
|
||||
// A move updates the file's path and may consolidate virtual-sibling books.
|
||||
// Schedule a folder scan so upsertBook can drain any remaining siblings.
|
||||
if (type === 'create') this.scheduleFolderScan(path, libraryId);
|
||||
} else if (result.type === 'book-transferred') {
|
||||
this.gateway.emitBookTransferred({ fromLibraryId: result.fromLibraryId, toLibraryId: result.toLibraryId, bookIds: result.bookIds });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWatchEvent(eventName: string): EventType | null {
|
||||
if (eventName === 'unlink' || eventName === 'unlinkDir') return 'delete';
|
||||
if (eventName === 'add' || eventName === 'addDir' || eventName === 'change') return 'create';
|
||||
return null;
|
||||
}
|
||||
|
||||
function waitForWatcherReady(watcher: FSWatcher): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleReady = () => {
|
||||
watcher.off('error', handleError);
|
||||
resolve();
|
||||
};
|
||||
const handleError = (error: unknown) => {
|
||||
watcher.off('ready', handleReady);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
|
||||
watcher.once('ready', handleReady);
|
||||
watcher.once('error', handleError);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BookMissingEvent, CoverRefreshedEvent, ScanProgressEvent } from '@bookorbit/types';
|
||||
import type { BookMissingEvent, BookTransferredEvent, CoverRefreshedEvent, ScanProgressEvent } from '@bookorbit/types';
|
||||
|
||||
import { ScanGateway } from './scan.gateway';
|
||||
|
||||
@@ -217,4 +217,16 @@ describe('remaining emit methods', () => {
|
||||
expect(emit).toHaveBeenCalledWith('book:restored', { libraryId: 3, bookIds: [2, 4] });
|
||||
expect(emit).toHaveBeenCalledWith('book:moved', { libraryId: 3, bookIds: [2, 4] });
|
||||
});
|
||||
|
||||
it('emits transferred book notifications to the source and destination library rooms', () => {
|
||||
const { gateway } = makeGateway();
|
||||
const { server, to, emit } = mockServer();
|
||||
gateway['server'] = server as any;
|
||||
|
||||
const event: BookTransferredEvent = { fromLibraryId: 3, toLibraryId: 8, bookIds: [2, 4] };
|
||||
gateway.emitBookTransferred(event);
|
||||
|
||||
expect(to).toHaveBeenCalledWith(['library:3', 'library:8']);
|
||||
expect(emit).toHaveBeenCalledWith('book:transferred', event);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
BookMissingEvent,
|
||||
BookMovedEvent,
|
||||
BookRestoredEvent,
|
||||
BookTransferredEvent,
|
||||
CoverRefreshedEvent,
|
||||
CoverRefreshProgressEvent,
|
||||
ScanBooksAddedEvent,
|
||||
@@ -111,6 +112,13 @@ export class ScanGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
this.server?.to(`library:${event.libraryId}`).emit('book:moved', event);
|
||||
}
|
||||
|
||||
emitBookTransferred(event: BookTransferredEvent): void {
|
||||
this.logger.debug(
|
||||
`[scanner.ws_emit] [book:transferred] fromLibraryId=${event.fromLibraryId} toLibraryId=${event.toLibraryId} bookCount=${event.bookIds.length}`,
|
||||
);
|
||||
this.server?.to([`library:${event.fromLibraryId}`, `library:${event.toLibraryId}`]).emit('book:transferred', event);
|
||||
}
|
||||
|
||||
emitBooksAdded(event: ScanBooksAddedEvent): void {
|
||||
this.server?.to(`library:${event.libraryId}`).emit('scan:books:added', event);
|
||||
}
|
||||
|
||||
@@ -45,15 +45,19 @@ function makeDb() {
|
||||
return state.proxy;
|
||||
};
|
||||
|
||||
const db: any = {};
|
||||
Object.assign(db, {
|
||||
select: vi.fn(() => next('select')),
|
||||
insert: vi.fn(() => next('insert')),
|
||||
update: vi.fn(() => next('update')),
|
||||
delete: vi.fn(() => next('delete')),
|
||||
transaction: vi.fn((callback: (tx: unknown) => unknown) => callback(db)),
|
||||
});
|
||||
|
||||
return {
|
||||
queues,
|
||||
chains,
|
||||
db: {
|
||||
select: vi.fn(() => next('select')),
|
||||
insert: vi.fn(() => next('insert')),
|
||||
update: vi.fn(() => next('update')),
|
||||
delete: vi.fn(() => next('delete')),
|
||||
},
|
||||
db,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,7 +158,7 @@ describe('ScannerRepository', () => {
|
||||
|
||||
it('queries book/file lookups with optional scope variants', async () => {
|
||||
const { repo, queues } = makeRepo();
|
||||
queues.select.push([{ id: 1, status: 'present', folderPath: '/books/A' }]);
|
||||
queues.select.push([{ id: 1, status: 'present', folderPath: '/books/A', primaryFileId: 90 }]);
|
||||
queues.select.push([{ bookId: 1, absolutePath: '/books/A/book.epub', format: 'epub' }]);
|
||||
queues.select.push([{ id: 7, absolutePath: '/books/A/book.epub' }]);
|
||||
queues.select.push([{ id: 90, bookId: 1, absolutePath: '/books/A/book.epub', ino: 999, sizeBytes: 10, mtime: new Date(), fileHash: 'x' }]);
|
||||
@@ -167,7 +171,7 @@ describe('ScannerRepository', () => {
|
||||
queues.select.push([{ id: 11, folderPath: '/books/A', status: 'missing' }]);
|
||||
queues.select.push([{ id: 12, folderPath: '/books/B', status: 'missing' }]);
|
||||
|
||||
await expect(repo.findBooksByLibraryFolder(4)).resolves.toEqual([{ id: 1, status: 'present', folderPath: '/books/A' }]);
|
||||
await expect(repo.findBooksByLibraryFolder(4)).resolves.toEqual([{ id: 1, status: 'present', folderPath: '/books/A', primaryFileId: 90 }]);
|
||||
await expect(repo.findPrimaryBookFilesByLibrary(4)).resolves.toEqual([{ bookId: 1, absolutePath: '/books/A/book.epub', format: 'epub' }]);
|
||||
await expect(repo.findPrimaryBookFilesByBookId(1)).resolves.toEqual([{ id: 7, absolutePath: '/books/A/book.epub' }]);
|
||||
await expect(repo.findBookFilesByLibraryFolder(4)).resolves.toEqual([
|
||||
@@ -213,8 +217,9 @@ describe('ScannerRepository', () => {
|
||||
{ id: 2, status: 'missing' },
|
||||
]);
|
||||
queues.update.push([]);
|
||||
queues.select.push([{ id: 99, libraryId: 5, libraryFolderId: 6, folderPath: '/source/Book', status: 'missing' }]);
|
||||
queues.update.push([{ id: 99, libraryId: 7, libraryFolderId: 8, folderPath: '/dest/Book', status: 'present' }]);
|
||||
queues.update.push([]);
|
||||
queues.select.push([]);
|
||||
|
||||
await expect(repo.findMissingBooksForLibraries([7, 8])).resolves.toEqual([
|
||||
{ id: 1, status: 'missing' },
|
||||
@@ -227,10 +232,12 @@ describe('ScannerRepository', () => {
|
||||
libraryFolderId: 8,
|
||||
folderPath: '/dest/Book',
|
||||
status: 'present',
|
||||
previousLibraryId: 5,
|
||||
libraryChanged: true,
|
||||
});
|
||||
await expect(repo.moveBookToLibrary(100, 7, 8, '/dest/Missing')).resolves.toBeNull();
|
||||
|
||||
expect(db.update).toHaveBeenCalledTimes(3);
|
||||
expect(db.update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('supports context lookups by inode/hash and file deletion', async () => {
|
||||
@@ -270,9 +277,11 @@ describe('ScannerRepository', () => {
|
||||
const { repo, queues } = makeRepo();
|
||||
queues.select.push([{ id: 44, folderPath: '/books/Found' }]);
|
||||
queues.select.push([]);
|
||||
queues.select.push([{ id: 44, libraryId: 1, status: 'missing' }]);
|
||||
|
||||
await expect(repo.findBookById(44)).resolves.toEqual({ id: 44, folderPath: '/books/Found' });
|
||||
await expect(repo.findBookById(45)).resolves.toBeNull();
|
||||
await expect(repo.findBooksByIds([44, 45])).resolves.toEqual([{ id: 44, libraryId: 1, status: 'missing' }]);
|
||||
});
|
||||
|
||||
it('returns empty card data for empty input and aggregates card data for requested books', async () => {
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
} from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
type MoveBookToLibraryResult = Pick<typeof books.$inferSelect, 'id' | 'libraryId' | 'libraryFolderId' | 'folderPath' | 'status'> & {
|
||||
previousLibraryId: number;
|
||||
libraryChanged: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ScannerRepository {
|
||||
@@ -98,6 +102,7 @@ export class ScannerRepository {
|
||||
id: books.id,
|
||||
status: books.status,
|
||||
folderPath: books.folderPath,
|
||||
primaryFileId: books.primaryFileId,
|
||||
})
|
||||
.from(books)
|
||||
.where(eq(books.libraryFolderId, libraryFolderId));
|
||||
@@ -282,18 +287,45 @@ export class ScannerRepository {
|
||||
}
|
||||
|
||||
async moveBookToLibrary(bookId: number, libraryId: number, libraryFolderId: number, folderPath: string) {
|
||||
const [book] = await this.db
|
||||
.update(books)
|
||||
.set({
|
||||
libraryId,
|
||||
libraryFolderId,
|
||||
folderPath,
|
||||
status: 'present',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(books.id, bookId))
|
||||
.returning();
|
||||
return book ?? null;
|
||||
return this.db.transaction(async (tx): Promise<MoveBookToLibraryResult | null> => {
|
||||
const [current] = await tx
|
||||
.select({
|
||||
id: books.id,
|
||||
libraryId: books.libraryId,
|
||||
libraryFolderId: books.libraryFolderId,
|
||||
folderPath: books.folderPath,
|
||||
status: books.status,
|
||||
})
|
||||
.from(books)
|
||||
.where(eq(books.id, bookId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
if (!current) return null;
|
||||
|
||||
const libraryChanged = current.libraryId !== libraryId;
|
||||
if (!libraryChanged && current.libraryFolderId === libraryFolderId && current.folderPath === folderPath && current.status === 'present') {
|
||||
return { ...current, previousLibraryId: current.libraryId, libraryChanged: false };
|
||||
}
|
||||
|
||||
const [book] = await tx
|
||||
.update(books)
|
||||
.set({
|
||||
libraryId,
|
||||
libraryFolderId,
|
||||
folderPath,
|
||||
status: 'present',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(books.id, bookId))
|
||||
.returning({
|
||||
id: books.id,
|
||||
libraryId: books.libraryId,
|
||||
libraryFolderId: books.libraryFolderId,
|
||||
folderPath: books.folderPath,
|
||||
status: books.status,
|
||||
});
|
||||
return book ? { ...book, previousLibraryId: current.libraryId, libraryChanged } : null;
|
||||
});
|
||||
}
|
||||
|
||||
async findBookFilesByBookId(bookId: number) {
|
||||
@@ -368,6 +400,93 @@ export class ScannerRepository {
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async findPresentDuplicateBookFilesByHash(fileHash: string, sourceBookId: number) {
|
||||
return this.db
|
||||
.select({
|
||||
file: bookFiles,
|
||||
bookId: books.id,
|
||||
libraryId: books.libraryId,
|
||||
libraryFolderId: books.libraryFolderId,
|
||||
folderPath: books.folderPath,
|
||||
bookAddedAt: books.addedAt,
|
||||
})
|
||||
.from(bookFiles)
|
||||
.innerJoin(books, eq(books.id, bookFiles.bookId))
|
||||
.where(and(eq(bookFiles.fileHash, fileHash), eq(bookFiles.role, 'content'), eq(books.status, 'present'), ne(books.id, sourceBookId)));
|
||||
}
|
||||
|
||||
async replaceDuplicateBookWithMovedBook(input: {
|
||||
sourceBookId: number;
|
||||
sourceFileId: number;
|
||||
duplicateBookId: number;
|
||||
duplicateFileId: number;
|
||||
}): Promise<{ bookId: number; libraryId: number; duplicateBookId: number } | null> {
|
||||
return this.db.transaction(async (tx) => {
|
||||
const [source] = await tx
|
||||
.select({
|
||||
bookId: books.id,
|
||||
fileId: bookFiles.id,
|
||||
})
|
||||
.from(bookFiles)
|
||||
.innerJoin(books, eq(books.id, bookFiles.bookId))
|
||||
.where(and(eq(books.id, input.sourceBookId), eq(bookFiles.id, input.sourceFileId), eq(books.status, 'missing')))
|
||||
.limit(1);
|
||||
if (!source) return null;
|
||||
|
||||
const [duplicate] = await tx
|
||||
.select({
|
||||
file: bookFiles,
|
||||
bookId: books.id,
|
||||
libraryId: books.libraryId,
|
||||
libraryFolderId: books.libraryFolderId,
|
||||
folderPath: books.folderPath,
|
||||
})
|
||||
.from(bookFiles)
|
||||
.innerJoin(books, eq(books.id, bookFiles.bookId))
|
||||
.where(and(eq(books.id, input.duplicateBookId), eq(bookFiles.id, input.duplicateFileId), eq(books.status, 'present')))
|
||||
.limit(1);
|
||||
if (!duplicate || duplicate.bookId === input.sourceBookId) return null;
|
||||
|
||||
await tx.delete(books).where(eq(books.id, input.duplicateBookId));
|
||||
await tx
|
||||
.update(books)
|
||||
.set({
|
||||
libraryId: duplicate.libraryId,
|
||||
libraryFolderId: duplicate.libraryFolderId,
|
||||
folderPath: duplicate.folderPath,
|
||||
status: 'present',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(books.id, input.sourceBookId));
|
||||
await tx
|
||||
.update(bookFiles)
|
||||
.set({
|
||||
libraryFolderId: duplicate.libraryFolderId,
|
||||
absolutePath: duplicate.file.absolutePath,
|
||||
relPath: duplicate.file.relPath,
|
||||
ino: duplicate.file.ino,
|
||||
sizeBytes: duplicate.file.sizeBytes,
|
||||
mtime: duplicate.file.mtime,
|
||||
fileHash: duplicate.file.fileHash,
|
||||
format: duplicate.file.format,
|
||||
role: duplicate.file.role,
|
||||
sortOrder: duplicate.file.sortOrder,
|
||||
durationSeconds: duplicate.file.durationSeconds,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(bookFiles.id, input.sourceFileId));
|
||||
await tx
|
||||
.update(books)
|
||||
.set({
|
||||
primaryFileId: input.sourceFileId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(books.id, input.sourceBookId));
|
||||
|
||||
return { bookId: input.sourceBookId, libraryId: duplicate.libraryId, duplicateBookId: input.duplicateBookId };
|
||||
});
|
||||
}
|
||||
|
||||
async findMissingBookFileWithContextByHash(fileHash: string) {
|
||||
const [row] = await this.db
|
||||
.select({
|
||||
@@ -394,6 +513,18 @@ export class ScannerRepository {
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async findBooksByIds(bookIds: number[]) {
|
||||
if (bookIds.length === 0) return [];
|
||||
return this.db
|
||||
.select({
|
||||
id: books.id,
|
||||
libraryId: books.libraryId,
|
||||
status: books.status,
|
||||
})
|
||||
.from(books)
|
||||
.where(inArray(books.id, bookIds));
|
||||
}
|
||||
|
||||
async findBookCardData(bookIds: number[]) {
|
||||
if (bookIds.length === 0) return { rows: [], authorRows: [], fileRows: [], genreRows: [] };
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ function makeRepo(overrides: Record<string, unknown> = {}) {
|
||||
markBooksAsMissing: vi.fn().mockResolvedValue(undefined),
|
||||
createBookFile: vi.fn().mockResolvedValue(makeBookFile()),
|
||||
updateBookFile: vi.fn().mockResolvedValue(makeBookFile()),
|
||||
findBookFileByAbsolutePath: vi.fn().mockResolvedValue(null),
|
||||
findBookFileByHash: vi.fn().mockResolvedValue(null),
|
||||
findBookFileWithContextByIno: vi.fn().mockResolvedValue(null),
|
||||
findMissingBookFileWithContextByIno: vi.fn().mockResolvedValue(null),
|
||||
@@ -113,6 +114,7 @@ function makeRepo(overrides: Record<string, unknown> = {}) {
|
||||
findBookFilesByBookId: vi.fn().mockResolvedValue([]),
|
||||
findBookFilesByBookIds: vi.fn().mockResolvedValue([]),
|
||||
findBookById: vi.fn().mockResolvedValue(null),
|
||||
findBooksByIds: vi.fn().mockResolvedValue([]),
|
||||
findBookCardData: vi.fn().mockResolvedValue({ rows: [], authorRows: [], fileRows: [], genreRows: [] }),
|
||||
deleteBookFile: vi.fn().mockResolvedValue(undefined),
|
||||
updateBookFolderPath: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -129,6 +131,7 @@ const mockGateway = {
|
||||
emitBookMissing: vi.fn(),
|
||||
emitBookRestored: vi.fn(),
|
||||
emitBookMoved: vi.fn(),
|
||||
emitBookTransferred: vi.fn(),
|
||||
emitBooksAdded: vi.fn(),
|
||||
emitCoverRefreshed: vi.fn(),
|
||||
emitCoverRefreshProgress: vi.fn(),
|
||||
@@ -376,6 +379,56 @@ describe('books unavailable notifications', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels pending unavailable notifications when the same books are restored', () => {
|
||||
const repo = makeRepo();
|
||||
const { service, notificationService } = makeService(repo);
|
||||
|
||||
service.bufferBooksUnavailableNotification(9, [2]);
|
||||
service.bufferBooksRestoredNotification(9, [2]);
|
||||
(service as any).flushBooksUnavailableNotification(9);
|
||||
|
||||
expect(notificationService.notify).toHaveBeenCalledTimes(0);
|
||||
|
||||
(service as any).flushBooksRestoredNotification(9);
|
||||
expect(notificationService.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: NotificationType.BooksRestored,
|
||||
meta: { libraryId: 9, count: 1 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('buffers missing websocket events and emits only books still missing in that library', async () => {
|
||||
const repo = makeRepo({
|
||||
findBooksByIds: vi.fn().mockResolvedValue([
|
||||
{ id: 1, libraryId: 9, status: 'missing' },
|
||||
{ id: 2, libraryId: 9, status: 'present' },
|
||||
{ id: 3, libraryId: 8, status: 'missing' },
|
||||
]),
|
||||
});
|
||||
const { service } = makeService(repo);
|
||||
|
||||
service.bufferBookMissingEvent(9, [1, 2, 3]);
|
||||
await (service as any).flushBookMissingEvent(9);
|
||||
|
||||
expect(repo.findBooksByIds).toHaveBeenCalledWith([1, 2, 3]);
|
||||
expect(mockGateway.emitBookMissing).toHaveBeenCalledWith({ libraryId: 9, bookIds: [1] });
|
||||
});
|
||||
|
||||
it('cancels pending missing websocket events when unavailable notifications are canceled', async () => {
|
||||
const repo = makeRepo({
|
||||
findBooksByIds: vi.fn().mockResolvedValue([{ id: 2, libraryId: 9, status: 'missing' }]),
|
||||
});
|
||||
const { service } = makeService(repo);
|
||||
|
||||
service.bufferBookMissingEvent(9, [2]);
|
||||
service.cancelBooksUnavailableNotification(9, [2]);
|
||||
await (service as any).flushBookMissingEvent(9);
|
||||
|
||||
expect(mockGateway.emitBookMissing).not.toHaveBeenCalled();
|
||||
expect(repo.findBooksByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses singular copy for one restored book', () => {
|
||||
const repo = makeRepo();
|
||||
const { service, notificationService } = makeService(repo);
|
||||
@@ -477,6 +530,55 @@ describe('genuinely new primary file', () => {
|
||||
expect(repo.updateBookPrimaryFile).toHaveBeenCalledWith(expect.any(Number), expect.any(Number));
|
||||
});
|
||||
|
||||
it('keeps the primary file when a concurrent scan already inserted the file row', async () => {
|
||||
const candidate = makeCandidate('/library/Author/Book', [makeFileStat()]);
|
||||
const existingFile = makeBookFile({ id: 42, bookId: 1 });
|
||||
mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
|
||||
|
||||
const repo = makeRepo({
|
||||
createBookFile: vi.fn().mockRejectedValue(new Error('duplicate absolute path')),
|
||||
findBookFileByAbsolutePath: vi.fn().mockResolvedValue({
|
||||
file: existingFile,
|
||||
libraryId: 1,
|
||||
primaryFileId: 42,
|
||||
}),
|
||||
});
|
||||
const done = awaitScan(repo);
|
||||
const { service } = makeService(repo);
|
||||
await service.startScan(1, 'manual');
|
||||
await done;
|
||||
|
||||
expect(repo.findBookFileByAbsolutePath).toHaveBeenCalledWith('/library/Author/Book/book.epub');
|
||||
expect(repo.updateBookPrimaryFile).toHaveBeenCalledWith(1, 42);
|
||||
expect(repo.updateBookPrimaryFile).not.toHaveBeenCalledWith(1, null);
|
||||
});
|
||||
|
||||
it('extracts metadata when an existing book had no primary file selected', async () => {
|
||||
const file = makeFileStat();
|
||||
const candidate = makeCandidate('/library/Author/Book', [file]);
|
||||
mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
|
||||
|
||||
const repo = makeRepo({
|
||||
findBooksByLibraryFolder: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
status: 'present',
|
||||
folderPath: '/library/Author/Book',
|
||||
primaryFileId: null,
|
||||
},
|
||||
]),
|
||||
findBookFilesByLibraryFolder: vi.fn().mockResolvedValue([makeBookFile({ id: 42, bookId: 1 })]),
|
||||
});
|
||||
const done = awaitScan(repo);
|
||||
const { service } = makeService(repo);
|
||||
await service.startScan(1, 'manual');
|
||||
await done;
|
||||
|
||||
expect(repo.createBookFile).not.toHaveBeenCalled();
|
||||
expect(repo.updateBookPrimaryFile).toHaveBeenCalledWith(1, 42);
|
||||
expect(mockMetadata.extractAndSave).toHaveBeenCalledWith(1, '/library/Author/Book/book.epub', 'epub');
|
||||
});
|
||||
|
||||
it('extracts metadata for new primary files in supported formats', async () => {
|
||||
const candidate = makeCandidate('/library/Author/Book', [makeFileStat()]);
|
||||
mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
|
||||
@@ -1524,7 +1626,8 @@ describe('cross-library transfer', () => {
|
||||
});
|
||||
|
||||
const done = awaitScan(repo);
|
||||
const { service } = makeService(repo);
|
||||
const { service, notificationService } = makeService(repo);
|
||||
service.bufferBooksUnavailableNotification(1, [42]);
|
||||
await service.startScan(2, 'manual');
|
||||
await done;
|
||||
|
||||
@@ -1540,6 +1643,9 @@ describe('cross-library transfer', () => {
|
||||
}),
|
||||
);
|
||||
expect(repo.updateBookPrimaryFile).toHaveBeenCalledWith(42, 500);
|
||||
expect(mockGateway.emitBookTransferred).toHaveBeenCalledWith({ fromLibraryId: 1, toLibraryId: 2, bookIds: [42] });
|
||||
(service as any).flushBooksUnavailableNotification(1);
|
||||
expect(notificationService.notify).not.toHaveBeenCalledWith(expect.objectContaining({ type: NotificationType.BooksUnavailable }));
|
||||
});
|
||||
|
||||
it('transfers a source book when inode matches and the previous path no longer exists', async () => {
|
||||
@@ -1598,6 +1704,7 @@ describe('cross-library transfer', () => {
|
||||
}),
|
||||
);
|
||||
expect(repo.updateBookPrimaryFile).toHaveBeenCalledWith(55, 510);
|
||||
expect(mockGateway.emitBookTransferred).toHaveBeenCalledWith({ fromLibraryId: 1, toLibraryId: 2, bookIds: [55] });
|
||||
});
|
||||
|
||||
it('transfers a missing source book via hash fallback when inode differs', async () => {
|
||||
@@ -1662,6 +1769,60 @@ describe('cross-library transfer', () => {
|
||||
}),
|
||||
);
|
||||
expect(repo.updateBookPrimaryFile).toHaveBeenCalledWith(77, 600);
|
||||
expect(mockGateway.emitBookTransferred).toHaveBeenCalledWith({ fromLibraryId: 1, toLibraryId: 3, bookIds: [77] });
|
||||
});
|
||||
|
||||
it('does not emit a transfer event when a racing scan reports no library change', async () => {
|
||||
const destinationFile = makeFileStat({
|
||||
absolutePath: '/dest/Inbox/book.epub',
|
||||
relPath: 'Inbox/book.epub',
|
||||
ino: 4242,
|
||||
});
|
||||
const sourceFile = makeBookFile({
|
||||
id: 500,
|
||||
bookId: 42,
|
||||
libraryFolderId: 10,
|
||||
absolutePath: '/source/Book/book.epub',
|
||||
relPath: 'Book/book.epub',
|
||||
ino: 4242,
|
||||
fileHash: 'transfer-hash',
|
||||
});
|
||||
|
||||
const repo = makeRepo({
|
||||
findLibraryFolders: vi.fn().mockResolvedValue([{ id: 20, path: '/dest', libraryId: 2 }]),
|
||||
findBooksByLibraryFolder: vi.fn().mockResolvedValue([]),
|
||||
findBookFilesByLibraryFolder: vi.fn().mockResolvedValue([]),
|
||||
findMissingBookFileWithContextByIno: vi.fn().mockResolvedValue({
|
||||
file: sourceFile,
|
||||
libraryId: 1,
|
||||
bookStatus: 'missing',
|
||||
folderPath: '/source/Book',
|
||||
libraryFolderPath: '/source',
|
||||
}),
|
||||
moveBookToLibrary: vi.fn().mockResolvedValue({
|
||||
id: 42,
|
||||
libraryId: 2,
|
||||
libraryFolderId: 20,
|
||||
folderPath: '/dest/Inbox',
|
||||
status: 'present',
|
||||
previousLibraryId: 2,
|
||||
libraryChanged: false,
|
||||
}),
|
||||
});
|
||||
mockFindCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/dest/Inbox', [destinationFile])],
|
||||
skippedDirs: new Set(),
|
||||
unchangedDirs: new Set(),
|
||||
dirMtimes: new Map(),
|
||||
});
|
||||
|
||||
const done = awaitScan(repo);
|
||||
const { service } = makeService(repo);
|
||||
await service.startScan(2, 'manual');
|
||||
await done;
|
||||
|
||||
expect(repo.moveBookToLibrary).toHaveBeenCalledWith(42, 2, 20, '/dest/Inbox');
|
||||
expect(mockGateway.emitBookTransferred).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not transfer ownership when destination folder already has a book', async () => {
|
||||
@@ -2411,6 +2572,63 @@ describe('book_per_file mode — scanBookFolder', () => {
|
||||
// Normal path: buildSingleBookCandidate is called for the folder
|
||||
expect(mockBuildSingleCandidate).toHaveBeenCalledWith('/library/Author/Book', '/library', expect.any(Array), expect.any(Function));
|
||||
});
|
||||
|
||||
it('reuses a same-library moved file when directory scan sees the add before unlink marks missing', async () => {
|
||||
const movedFile = makeFileStat({
|
||||
absolutePath: '/library/Moved/Alpha.epub',
|
||||
relPath: 'Moved/Alpha.epub',
|
||||
ino: 3001,
|
||||
});
|
||||
const repo = makeRepo({
|
||||
findLibrarySettings: vi.fn().mockResolvedValue({
|
||||
allowedFormats: [],
|
||||
formatPriority: DEFAULT_FORMAT_PRIORITY,
|
||||
metadataPrecedence: ['embedded'],
|
||||
excludePatterns: [],
|
||||
organizationMode: 'book_per_file',
|
||||
}),
|
||||
findLibraryFolders: vi.fn().mockResolvedValue([{ id: 1, path: '/library', libraryId: 1 }]),
|
||||
findBooksByFolderPath: vi.fn().mockResolvedValue([]),
|
||||
findBookFilesByBookIds: vi.fn().mockResolvedValue([]),
|
||||
findMissingBookFileWithContextByIno: vi.fn().mockResolvedValue(null),
|
||||
findBookFileWithContextByIno: vi.fn().mockResolvedValue({
|
||||
file: {
|
||||
id: 9,
|
||||
bookId: 7,
|
||||
absolutePath: '/library/Alpha.epub',
|
||||
ino: 3001,
|
||||
sizeBytes: 1024,
|
||||
mtime: new Date('2024-01-01'),
|
||||
fileHash: null,
|
||||
},
|
||||
libraryId: 1,
|
||||
bookStatus: 'present',
|
||||
folderPath: '/library/Alpha.epub',
|
||||
libraryFolderPath: '/library',
|
||||
}),
|
||||
});
|
||||
mockFindLooseCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/Moved/Alpha.epub', [movedFile])],
|
||||
skippedDirs: new Set(),
|
||||
unchangedDirs: new Set(),
|
||||
dirMtimes: new Map(),
|
||||
});
|
||||
mockStat.mockRejectedValue(new Error('ENOENT'));
|
||||
const { service } = makeService(repo);
|
||||
|
||||
await (service as any).scanBookDirectory('/library/Moved', 1);
|
||||
|
||||
expect(repo.moveBookToLibrary).toHaveBeenCalledWith(7, 1, 1, '/library/Moved/Alpha.epub');
|
||||
expect(repo.createBook).not.toHaveBeenCalled();
|
||||
expect(repo.updateBookFile).toHaveBeenCalledWith(
|
||||
9,
|
||||
expect.objectContaining({
|
||||
bookId: 7,
|
||||
absolutePath: '/library/Moved/Alpha.epub',
|
||||
relPath: 'Moved/Alpha.epub',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bootstrap, wrappers, and cover refresh', () => {
|
||||
|
||||
@@ -2,7 +2,14 @@ import { ConflictException, Injectable, Logger, NotFoundException, OnApplication
|
||||
import { createHash } from 'crypto';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
|
||||
import type { BookMissingEvent, CoverRefreshedEvent, CoverRefreshProgressEvent, ScanBooksAddedEvent, ScanProgressEvent } from '@bookorbit/types';
|
||||
import type {
|
||||
BookMissingEvent,
|
||||
BookTransferredEvent,
|
||||
CoverRefreshedEvent,
|
||||
CoverRefreshProgressEvent,
|
||||
ScanBooksAddedEvent,
|
||||
ScanProgressEvent,
|
||||
} from '@bookorbit/types';
|
||||
import { NotificationType } from '@bookorbit/types';
|
||||
import { AchievementEventsService, ACHIEVEMENT_EVENT_LIBRARY_CATALOG_CHANGED } from '../achievement/achievement-events.service';
|
||||
import { BookMetadataFetchOrchestratorService } from '../book-metadata-fetch/book-metadata-fetch-orchestrator.service';
|
||||
@@ -33,6 +40,7 @@ interface BookEntry {
|
||||
id: number;
|
||||
status: string;
|
||||
folderPath: string;
|
||||
primaryFileId?: number | null;
|
||||
}
|
||||
|
||||
interface FileByPathEntry {
|
||||
@@ -95,6 +103,7 @@ const COVER_REFRESH_BATCH_SIZE = 5;
|
||||
const BOOK_EMIT_BUFFER_SIZE = 20;
|
||||
const BOOK_EMIT_FLUSH_INTERVAL_MS = 1000;
|
||||
const BOOK_STATUS_NOTIFY_DEBOUNCE_MS = 1000;
|
||||
const BOOK_MISSING_NOTIFY_DEBOUNCE_MS = 5000;
|
||||
const WATCHER_NOTIFY_DEBOUNCE_MS = 30_000;
|
||||
const TARGETED_BOOK_SCAN_MAX_CONCURRENCY = 8;
|
||||
const MISSING_FILE_STAT_BATCH_SIZE = 50;
|
||||
@@ -195,6 +204,8 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
// ── Missing book notification buffer (debounced, 1s) ──────────────────────
|
||||
private readonly booksUnavailableNotifyBuffer = new Map<number, Set<number>>();
|
||||
private readonly booksUnavailableNotifyTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
private readonly bookMissingEmitBuffer = new Map<number, Set<number>>();
|
||||
private readonly bookMissingEmitTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
|
||||
// ── Restored book notification buffer (debounced, 1s) ─────────────────────
|
||||
private readonly booksRestoredNotifyBuffer = new Map<number, Set<number>>();
|
||||
@@ -219,7 +230,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
private static buildLookupMaps(
|
||||
knownBooks: Array<{ id: number; status: string; folderPath: string }>,
|
||||
knownBooks: Array<{ id: number; status: string; folderPath: string; primaryFileId?: number | null }>,
|
||||
knownFiles: Array<{
|
||||
id: number;
|
||||
bookId: number;
|
||||
@@ -232,7 +243,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
}>,
|
||||
): ScanLookupMaps {
|
||||
const bookByFolderPath = new Map<string, BookEntry>(
|
||||
knownBooks.map((b) => [b.folderPath, { id: b.id, status: b.status, folderPath: b.folderPath }]),
|
||||
knownBooks.map((b) => [b.folderPath, { id: b.id, status: b.status, folderPath: b.folderPath, primaryFileId: b.primaryFileId }]),
|
||||
);
|
||||
|
||||
const booksByParentDir = new Map<string, BookEntry[]>();
|
||||
@@ -243,7 +254,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
arr = [];
|
||||
booksByParentDir.set(parentDir, arr);
|
||||
}
|
||||
arr.push({ id: b.id, status: b.status, folderPath: b.folderPath });
|
||||
arr.push({ id: b.id, status: b.status, folderPath: b.folderPath, primaryFileId: b.primaryFileId });
|
||||
}
|
||||
|
||||
const fileByPath = new Map<string, FileByPathEntry>(
|
||||
@@ -341,10 +352,75 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
if (existing) clearTimeout(existing);
|
||||
this.booksUnavailableNotifyTimers.set(
|
||||
libraryId,
|
||||
setTimeout(() => this.flushBooksUnavailableNotification(libraryId), BOOK_STATUS_NOTIFY_DEBOUNCE_MS),
|
||||
setTimeout(() => this.flushBooksUnavailableNotification(libraryId), BOOK_MISSING_NOTIFY_DEBOUNCE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
bufferBookMissingEvent(libraryId: number, bookIds: number[]): void {
|
||||
if (bookIds.length === 0) return;
|
||||
const current = this.bookMissingEmitBuffer.get(libraryId) ?? new Set<number>();
|
||||
for (const bookId of bookIds) current.add(bookId);
|
||||
this.bookMissingEmitBuffer.set(libraryId, current);
|
||||
|
||||
const existing = this.bookMissingEmitTimers.get(libraryId);
|
||||
if (existing) clearTimeout(existing);
|
||||
this.bookMissingEmitTimers.set(
|
||||
libraryId,
|
||||
setTimeout(() => {
|
||||
this.flushBookMissingEvent(libraryId).catch((err) => {
|
||||
this.logger.warn(
|
||||
`[scanner.emit_book_missing] [fail] libraryId=${libraryId} errorClass=${err instanceof Error ? err.name : 'Error'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - failed to emit missing books`,
|
||||
);
|
||||
});
|
||||
}, BOOK_MISSING_NOTIFY_DEBOUNCE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
cancelBooksUnavailableNotification(libraryId: number, bookIds: number[]): void {
|
||||
if (bookIds.length === 0) return;
|
||||
this.cancelBookMissingEvent(libraryId, bookIds);
|
||||
|
||||
const current = this.booksUnavailableNotifyBuffer.get(libraryId);
|
||||
if (!current) return;
|
||||
|
||||
for (const bookId of bookIds) current.delete(bookId);
|
||||
if (current.size > 0) return;
|
||||
|
||||
this.booksUnavailableNotifyBuffer.delete(libraryId);
|
||||
const existing = this.booksUnavailableNotifyTimers.get(libraryId);
|
||||
if (existing) clearTimeout(existing);
|
||||
this.booksUnavailableNotifyTimers.delete(libraryId);
|
||||
}
|
||||
|
||||
private cancelBookMissingEvent(libraryId: number, bookIds: number[]): void {
|
||||
const current = this.bookMissingEmitBuffer.get(libraryId);
|
||||
if (!current) return;
|
||||
|
||||
for (const bookId of bookIds) current.delete(bookId);
|
||||
if (current.size > 0) return;
|
||||
|
||||
this.bookMissingEmitBuffer.delete(libraryId);
|
||||
const existing = this.bookMissingEmitTimers.get(libraryId);
|
||||
if (existing) clearTimeout(existing);
|
||||
this.bookMissingEmitTimers.delete(libraryId);
|
||||
}
|
||||
|
||||
private async flushBookMissingEvent(libraryId: number): Promise<void> {
|
||||
const existing = this.bookMissingEmitTimers.get(libraryId);
|
||||
if (existing) clearTimeout(existing);
|
||||
this.bookMissingEmitTimers.delete(libraryId);
|
||||
|
||||
const bookIds = [...(this.bookMissingEmitBuffer.get(libraryId) ?? [])];
|
||||
this.bookMissingEmitBuffer.delete(libraryId);
|
||||
if (bookIds.length === 0) return;
|
||||
|
||||
const rows = await this.scannerRepo.findBooksByIds(bookIds);
|
||||
const stillMissingIds = rows.filter((book) => book.libraryId === libraryId && book.status === 'missing').map((book) => book.id);
|
||||
if (stillMissingIds.length === 0) return;
|
||||
|
||||
this.scanGateway.emitBookMissing({ libraryId, bookIds: stillMissingIds } satisfies BookMissingEvent);
|
||||
}
|
||||
|
||||
private flushBooksUnavailableNotification(libraryId: number): void {
|
||||
const existing = this.booksUnavailableNotifyTimers.get(libraryId);
|
||||
if (existing) clearTimeout(existing);
|
||||
@@ -371,6 +447,8 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
|
||||
bufferBooksRestoredNotification(libraryId: number, bookIds: number[]): void {
|
||||
if (bookIds.length === 0) return;
|
||||
this.cancelBooksUnavailableNotification(libraryId, bookIds);
|
||||
|
||||
const current = this.booksRestoredNotifyBuffer.get(libraryId) ?? new Set<number>();
|
||||
for (const bookId of bookIds) current.add(bookId);
|
||||
this.booksRestoredNotifyBuffer.set(libraryId, current);
|
||||
@@ -814,7 +892,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
const knownBooks = await this.scannerRepo.findBooksByFolderPath(folderPath, libraryId);
|
||||
const knownFiles = await this.scannerRepo.findBookFilesByBookIds(knownBooks.map((b) => b.id));
|
||||
return ScannerService.buildLookupMaps(
|
||||
knownBooks.map((b) => ({ id: b.id, status: b.status, folderPath: b.folderPath })),
|
||||
knownBooks.map((b) => ({ id: b.id, status: b.status, folderPath: b.folderPath, primaryFileId: b.primaryFileId })),
|
||||
knownFiles.map((f) => ({
|
||||
id: f.id,
|
||||
bookId: f.bookId,
|
||||
@@ -1430,7 +1508,8 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
// - Extraction only fires when at least one configured metadata source is new, reassigned, or changed.
|
||||
|
||||
const metadataSources = this.buildMetadataExtractionSources(registeredFiles, winner, metadataPrecedence);
|
||||
const shouldExtractMetadata = metadataSources.some((source) => hasMetadataSourceChanged(source.file));
|
||||
const shouldExtractMetadata =
|
||||
metadataSources.some((source) => hasMetadataSourceChanged(source.file)) || (book.primaryFileId === null && winner !== null);
|
||||
const audioContentFiles = contentFiles.filter((f) => f.format !== null && isAudioFormat(f.format!));
|
||||
const changedAudioFiles = audioContentFiles.filter(hasMetadataSourceChanged);
|
||||
const winnerIsAudio = winner !== null && winner.format !== null && isAudioFormat(winner.format);
|
||||
@@ -1558,8 +1637,8 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
candidate: BookCandidate,
|
||||
libraryId: number,
|
||||
libraryFolderId: number,
|
||||
bookByFolderPath: Map<string, { id: number; status: string; folderPath: string }>,
|
||||
booksByParentDir: Map<string, Array<{ id: number; status: string; folderPath: string }>>,
|
||||
bookByFolderPath: Map<string, BookEntry>,
|
||||
booksByParentDir: Map<string, BookEntry[]>,
|
||||
fileByPath: Map<string, FileByPathEntry>,
|
||||
fileByIno: Map<number, FileByInoEntry>,
|
||||
counts: ScanCounts,
|
||||
@@ -1626,8 +1705,9 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
`[scanner.upsert_book] [fail] libraryId=${libraryId} bookId=${book.id} action=schedule_metadata_fetch errorClass=${err.name} error="${sanitizeLogValue(err.message)}" - metadata fetch schedule failed`,
|
||||
),
|
||||
);
|
||||
bookByFolderPath.set(candidate.folderPath, { id: book.id, status: book.status, folderPath: book.folderPath });
|
||||
return book;
|
||||
const entry = { id: book.id, status: book.status, folderPath: book.folderPath, primaryFileId: book.primaryFileId ?? null };
|
||||
bookByFolderPath.set(candidate.folderPath, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
if (existing.status === 'missing') {
|
||||
@@ -1718,6 +1798,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
id: sourceBook.id,
|
||||
status: restored ? 'present' : sourceBook.status,
|
||||
folderPath: candidate.folderPath,
|
||||
primaryFileId: sourceBook.primaryFileId,
|
||||
};
|
||||
bookByFolderPath.delete(sourceBook.folderPath);
|
||||
bookByFolderPath.set(candidate.folderPath, moved);
|
||||
@@ -1765,9 +1846,9 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
candidate: BookCandidate,
|
||||
libraryId: number,
|
||||
libraryFolderId: number,
|
||||
bookByFolderPath: Map<string, { id: number; status: string; folderPath: string }>,
|
||||
bookByFolderPath: Map<string, BookEntry>,
|
||||
counts: ScanCounts,
|
||||
): Promise<{ id: number; status: string; folderPath: string } | null> {
|
||||
): Promise<BookEntry | null> {
|
||||
const contentFiles = candidate.files.filter((file) => {
|
||||
const role = file.role ?? classifyFile(file.absolutePath).role;
|
||||
return role === 'content' && file.sizeBytes > 0;
|
||||
@@ -1775,12 +1856,14 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
if (contentFiles.length === 0) return null;
|
||||
|
||||
let sourceBookId: number | null = null;
|
||||
let sourceLibraryId: number | null = null;
|
||||
|
||||
for (const file of contentFiles) {
|
||||
if (file.ino === 0) continue;
|
||||
const byIno = await this.scannerRepo.findMissingBookFileWithContextByIno(file.ino);
|
||||
if (!byIno) continue;
|
||||
sourceBookId = byIno.file.bookId;
|
||||
sourceLibraryId = byIno.libraryId;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1789,10 +1872,10 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
if (file.ino === 0) continue;
|
||||
const byIno = await this.scannerRepo.findBookFileWithContextByIno(file.ino);
|
||||
if (!byIno || byIno.file.absolutePath === file.absolutePath) continue;
|
||||
if (byIno.libraryId === libraryId) continue;
|
||||
const previousPathStat = await stat(byIno.file.absolutePath).catch(() => null);
|
||||
if (previousPathStat?.isFile()) continue;
|
||||
sourceBookId = byIno.file.bookId;
|
||||
sourceLibraryId = byIno.libraryId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1811,15 +1894,16 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
const byHash = await this.scannerRepo.findMissingBookFileWithContextByHash(fileHash);
|
||||
if (byHash) {
|
||||
sourceBookId = byHash.file.bookId;
|
||||
sourceLibraryId = byHash.libraryId;
|
||||
break;
|
||||
}
|
||||
|
||||
const byHashAny = await this.scannerRepo.findBookFileWithContextByHash(fileHash);
|
||||
if (!byHashAny || byHashAny.file.absolutePath === file.absolutePath) continue;
|
||||
if (byHashAny.libraryId === libraryId) continue;
|
||||
const previousPathStat = await stat(byHashAny.file.absolutePath).catch(() => null);
|
||||
if (previousPathStat?.isFile()) continue;
|
||||
sourceBookId = byHashAny.file.bookId;
|
||||
sourceLibraryId = byHashAny.libraryId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1830,6 +1914,16 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
if (!moved) return null;
|
||||
|
||||
counts.updatedCount++;
|
||||
const previousLibraryId = moved.previousLibraryId ?? sourceLibraryId;
|
||||
if (previousLibraryId != null) this.cancelBooksUnavailableNotification(previousLibraryId, [sourceBookId]);
|
||||
const libraryChanged = moved.libraryChanged ?? (previousLibraryId != null && previousLibraryId !== libraryId);
|
||||
if (libraryChanged && previousLibraryId != null && previousLibraryId !== libraryId) {
|
||||
this.scanGateway.emitBookTransferred({
|
||||
fromLibraryId: previousLibraryId,
|
||||
toLibraryId: libraryId,
|
||||
bookIds: [moved.id],
|
||||
} satisfies BookTransferredEvent);
|
||||
}
|
||||
const transferred = { id: moved.id, status: moved.status, folderPath: moved.folderPath };
|
||||
bookByFolderPath.set(candidate.folderPath, transferred);
|
||||
this.logger.log(
|
||||
@@ -2198,7 +2292,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
const created = await this.scannerRepo.createBookFile({
|
||||
const fileData = {
|
||||
bookId,
|
||||
libraryFolderId,
|
||||
absolutePath: fileStat.absolutePath,
|
||||
@@ -2210,7 +2304,34 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
format,
|
||||
role,
|
||||
sortOrder,
|
||||
});
|
||||
};
|
||||
let created: Awaited<ReturnType<ScannerRepository['createBookFile']>>;
|
||||
try {
|
||||
created = await this.scannerRepo.createBookFile(fileData);
|
||||
} catch (err) {
|
||||
const concurrent = await this.scannerRepo.findBookFileByAbsolutePath(fileStat.absolutePath);
|
||||
if (!concurrent) throw err;
|
||||
return this.resolveExistingFilePath(
|
||||
{
|
||||
id: concurrent.file.id,
|
||||
bookId: concurrent.file.bookId,
|
||||
ino: concurrent.file.ino,
|
||||
sizeBytes: concurrent.file.sizeBytes,
|
||||
mtime: concurrent.file.mtime,
|
||||
fileHash: concurrent.file.fileHash,
|
||||
sortOrder: concurrent.file.sortOrder,
|
||||
},
|
||||
fileStat,
|
||||
format,
|
||||
role,
|
||||
sortOrder,
|
||||
bookId,
|
||||
libraryFolderId,
|
||||
fileByPath,
|
||||
fileByIno,
|
||||
counts,
|
||||
);
|
||||
}
|
||||
counts.addedCount++;
|
||||
fileByPath.set(fileStat.absolutePath, {
|
||||
id: created.id,
|
||||
|
||||
Reference in New Issue
Block a user