fix(book-dock): use file path as folderPath when finalizing into a book_per_file library

In book_per_file mode the scanner identifies books by the file's absolute
path (not its parent directory). The finalize service was always using
dirname(destPath) regardless of organization mode, so the scanner would
see the freshly-moved file as unknown, create a second book record, and
then steal the book_file from the first record via the onConflictDoUpdate
upsert - leaving the original book as a ghost with a stale primary_file_id.

Fix: check library.organizationMode after fetching the library and pass
destPath directly as bookFolderPath when the mode is book_per_file.

Add a dedicated test case that asserts createBookRecord receives the file
path (not its parent dir) in book_per_file mode.
This commit is contained in:
neon
2026-05-11 03:48:30 +00:00
parent 8e8b8ae303
commit 98d21166f5
2 changed files with 25 additions and 1 deletions
@@ -440,6 +440,30 @@ describe('BookDockFinalizeService', () => {
success: true,
bookId: 555,
});
expect(processor.createBookRecord).toHaveBeenCalledWith(5, 9, '/library/new', '/library/new/book.epub', 'new/book.epub', 'epub', 100);
});
it('uses the file path as bookFolderPath in book_per_file mode', async () => {
const { service, processor } = makeService();
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({
id: 5,
allowedFormats: ['epub'],
fileNamingPattern: null,
organizationMode: 'book_per_file',
} as never);
vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never);
vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/new/book.epub' as never);
vi.spyOn(service as never, 'findDuplicate').mockResolvedValue(null as never);
vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never);
vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never);
mockAccess.mockRejectedValueOnce(new Error('missing'));
mockStat.mockResolvedValueOnce({ size: 100 } as never);
processor.createBookRecord.mockResolvedValueOnce({ bookId: 556 });
await expect(
(service as any).finalizeFile(makeRow({ targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, new Map(), 1, true),
).resolves.toMatchObject({ success: true, bookId: 556 });
expect(processor.createBookRecord).toHaveBeenCalledWith(5, 9, '/library/new/book.epub', '/library/new/book.epub', 'new/book.epub', 'epub', 100);
});
});
@@ -209,7 +209,7 @@ export class BookDockFinalizeService implements OnModuleInit {
let bookId: number;
try {
const { size } = await stat(destPath);
const bookFolderPath = dirname(destPath);
const bookFolderPath = library.organizationMode === 'book_per_file' ? destPath : dirname(destPath);
({ bookId } = await this.processor.createBookRecord(
libraryId,
folder.id,