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:
neon
2026-06-21 18:16:12 +00:00
parent c00bd57ca5
commit 2143b00cc5
24 changed files with 2246 additions and 170 deletions
+3 -2
View File
@@ -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 }
}
+4 -1
View File
@@ -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 })
+44 -17
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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[];
+3 -3
View File
@@ -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
View File
@@ -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 () => {
+143 -12
View File
@@ -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', () => {
+138 -17
View File
@@ -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,