feat(scanner): align bookdock duplicate detection with upload behavior

Replace metadata-based duplicate checks (ISBN, title/author) during BookDock finalization with destination path lookup. This allows books with shared or incorrect metadata to be imported whenever their resolved library target paths differ, matching the standard upload workflow.

Closes #736
This commit is contained in:
neon
2026-07-22 20:06:49 +00:00
parent fb93f6ec20
commit 58c5b422d3
14 changed files with 349 additions and 622 deletions
@@ -275,38 +275,6 @@ function goToBook(bookId: number) {
handleClose()
}
async function handleReimportDuplicate(fileId: number) {
if (!result.value || reimportingIds.has(fileId)) return
reimportingIds.add(fileId)
try {
const payload: Record<string, unknown> = {
fileIds: [fileId],
overrides: [{ fileId, skipDuplicateCheck: true }],
}
if (defaultLibraryId.value !== null) payload.defaultLibraryId = defaultLibraryId.value
if (defaultFolderId.value !== null) payload.defaultFolderId = defaultFolderId.value
const res = await api('/api/v1/book-dock/finalize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) return
const newResult = await res.json()
const fileResult = (newResult.results as typeof result.value.results)[0]
if (!fileResult) return
const entry = result.value.results.find((r) => r.fileId === fileId)
if (entry) {
Object.assign(entry, fileResult)
if (fileResult.success) {
result.value.succeeded++
result.value.failed--
}
}
} finally {
reimportingIds.delete(fileId)
}
}
async function discardDuplicateSelection(payload: FinalizePayload): Promise<BookDockDiscardDuplicatesResult | null> {
const res = await api('/api/v1/book-dock/finalize/discard-duplicates', {
method: 'POST',
@@ -578,15 +546,6 @@ async function handleDiscardResultDuplicates() {
>
{{ t('bookDock.finalizeDialog.viewExisting') }} <ExternalLink class="size-3" />
</button>
<button
v-if="!r.success && r.isDuplicate"
class="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 shrink-0 transition-colors"
:disabled="reimportingIds.has(r.fileId)"
@click="handleReimportDuplicate(r.fileId)"
>
<Loader2 v-if="reimportingIds.has(r.fileId)" class="size-3 animate-spin" />
{{ t('bookDock.finalizeDialog.importAnyway') }}
</button>
<button
v-if="!r.success && !r.isDuplicate && r.message && !isFileExistsError(r.message)"
class="text-xs text-red-500 flex items-center gap-1 shrink-0 hover:text-red-600 transition-colors"
@@ -1,10 +1,11 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { BookDockFinalizeResult } from '@bookorbit/types'
import BookDockFinalizeDialog from '../BookDockFinalizeDialog.vue'
const apiMock = vi.fn<(...args: unknown[]) => Promise<Response>>()
const finalizeResult = ref(null)
const finalizeResult = ref<BookDockFinalizeResult | null>(null)
const finalizeLoading = ref(false)
const finalizeError = ref<string | null>(null)
const finalizeMock = vi.fn<(...args: unknown[]) => Promise<void>>()
@@ -97,7 +98,7 @@ describe('BookDockFinalizeDialog', () => {
fileName: 'duplicate.epub',
status: 'duplicate',
existingBookId: 42,
message: 'Duplicate: this book already exists in the library',
message: 'A file with this name already exists at the target location',
},
{ fileId: 11, fileName: 'ready.epub', newName: 'ready.epub', status: 'ready' },
],
@@ -148,4 +149,32 @@ describe('BookDockFinalizeDialog', () => {
})
expect(wrapper.text()).not.toContain('1 already in library')
})
it('offers navigation and rename for an indexed destination collision without an import-anyway action', async () => {
finalizeResult.value = {
total: 1,
succeeded: 0,
failed: 1,
results: [
{
fileId: 10,
fileName: 'duplicate.epub',
newName: 'Author/Title/duplicate.epub',
success: false,
isDuplicate: true,
existingBookId: 42,
message: 'A file with this name already exists at the target location',
},
],
}
const wrapper = mountDialog()
await flushPromises()
expect(wrapper.text()).toContain('View existing')
expect(wrapper.text()).toContain('Already exists - save as')
expect(wrapper.text()).toContain('Import')
expect(wrapper.text()).not.toContain('Import anyway')
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
})
})
-1
View File
@@ -4016,7 +4016,6 @@
"failedWithDuplicates": "{failed} fehlgeschlagen ({count} Duplikate) | {failed} fehlgeschlagen ({count} Duplikat) | {failed} fehlgeschlagen ({count} Duplikate)",
"view": "Anzeigen",
"viewExisting": "Vorhandenes anzeigen",
"importAnyway": "Trotzdem importieren",
"hide": "Ausblenden",
"details": "Details",
"alreadyExistsSaveAs": "Existiert bereits - speichern als:",
-1
View File
@@ -3974,7 +3974,6 @@
"failedWithDuplicates": "{failed} failed ({count} duplicates) | {failed} failed ({count} duplicate) | {failed} failed ({count} duplicates)",
"view": "View",
"viewExisting": "View existing",
"importAnyway": "Import anyway",
"hide": "Hide",
"details": "Details",
"alreadyExistsSaveAs": "Already exists - save as:",
-1
View File
@@ -4016,7 +4016,6 @@
"failedWithDuplicates": "{failed} mislukt ({count} duplicaten) | {failed} mislukt ({count} duplicaat) | {failed} mislukt ({count} duplicaten)",
"view": "Bekijken",
"viewExisting": "Bestaande bekijken",
"importAnyway": "Toch importeren",
"hide": "Verbergen",
"details": "Details",
"alreadyExistsSaveAs": "Bestaat al - opslaan als:",
-1
View File
@@ -4016,7 +4016,6 @@
"failedWithDuplicates": "{failed} falhou ({count} duplicatas) | {failed} falhou ({count} duplicata) | {failed} falharam ({count} duplicatas)",
"view": "Ver",
"viewExisting": "Ver existente",
"importAnyway": "Importar de qualquer forma",
"hide": "Esconder",
"details": "Detalhes",
"alreadyExistsSaveAs": "Já existe - salvar como:",
-1
View File
@@ -4016,7 +4016,6 @@
"failedWithDuplicates": "{failed} neuspešnih ({count} podvojenih) | {failed} neuspešna ({count} podvojena) | {failed} neuspešnih ({count} podvojenih)",
"view": "Ogled",
"viewExisting": "Ogled obstoječega",
"importAnyway": "Vseeno uvozi",
"hide": "Skrij",
"details": "Podrobnosti",
"alreadyExistsSaveAs": "Že obstaja - shrani kot:",
-1
View File
@@ -93,7 +93,6 @@ export interface BookDockFinalizeOverride {
fileId: number;
libraryId?: number;
folderId?: number;
skipDuplicateCheck?: boolean;
targetFileName?: string;
}
@@ -27,6 +27,7 @@ function makeService() {
findByIds: vi.fn(),
findSelectionBatch: vi.fn(),
findAllIds: vi.fn(),
findExistingBooksByAbsolutePaths: vi.fn().mockResolvedValue([]),
deleteById: vi.fn(),
deleteByIds: vi.fn(),
};
@@ -142,7 +143,7 @@ describe('BookDockFinalizeService', () => {
mockReadFile.mockReset();
mockStat.mockReset();
mockUnlink.mockReset();
mockAccess.mockRejectedValue(new Error('ENOENT'));
mockAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockReadFile.mockResolvedValue(Buffer.from('cover-bytes'));
mockStat.mockResolvedValue({ size: 100 } as never);
mockUnlink.mockResolvedValue(undefined);
@@ -392,8 +393,11 @@ describe('BookDockFinalizeService', () => {
const { service, repo } = makeService();
const rowOne = makeRow({ id: 1 });
repo.findByIds.mockResolvedValue([rowOne]);
vi.spyOn(service as never, 'buildDuplicateLookup').mockResolvedValue(new Map() as never);
vi.spyOn(service as never, 'finalizeFile').mockResolvedValueOnce({
vi.spyOn(service as never, 'prepareFinalizeBatch').mockResolvedValue({
analyses: [{ fileId: 1, fileName: 'book.epub', row: rowOne, status: 'ready' }],
existingDestinations: new Map(),
} as never);
vi.spyOn(service as never, 'finalizePreparedCandidate').mockResolvedValueOnce({
fileId: 1,
fileName: 'book.epub',
success: true,
@@ -418,13 +422,22 @@ describe('BookDockFinalizeService', () => {
it('iterates selectAll batches until no rows remain', async () => {
const { service, repo } = makeService();
repo.findSelectionBatch.mockResolvedValueOnce([makeRow({ id: 1 }), makeRow({ id: 2 })]).mockResolvedValueOnce([]);
vi.spyOn(service as never, 'buildDuplicateLookup').mockResolvedValue(new Map() as never);
vi.spyOn(service as never, 'finalizeFile').mockResolvedValue({
fileId: 1,
fileName: 'book.epub',
success: true,
bookId: 10,
} as never);
vi.spyOn(service as never, 'prepareFinalizeBatch').mockImplementation(
(rows: unknown[]) =>
Promise.resolve({
analyses: rows.map((row: any) => ({ fileId: row.id, fileName: row.fileName, row, status: 'ready' })),
existingDestinations: new Map(),
}) as never,
);
vi.spyOn(service as never, 'finalizePreparedCandidate').mockImplementation(
(analysis: any) =>
Promise.resolve({
fileId: analysis.fileId,
fileName: analysis.fileName,
success: true,
bookId: analysis.fileId + 9,
}) as never,
);
vi.spyOn(service as never, 'emitSummary').mockResolvedValue(undefined as never);
const result = await service.finalize(7, true, [], true, [], 5, 9, [], 'ready', 'foo');
@@ -472,13 +485,13 @@ describe('BookDockFinalizeService', () => {
expect(validator.validateFormat).toHaveBeenCalled();
});
it('marks rows as duplicates when duplicate lookup finds an existing book', async () => {
const { service } = makeService();
it('marks an occupied indexed destination as a duplicate', async () => {
const { service, repo } = makeService();
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } 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.epub' as never);
mockAccess.mockRejectedValueOnce(new Error('missing'));
vi.spyOn(service as never, 'findDuplicate').mockResolvedValue(77 as never);
repo.findExistingBooksByAbsolutePaths.mockResolvedValue([{ absolutePath: '/library/new.epub', bookId: 77, libraryId: 5 }]);
mockAccess.mockResolvedValueOnce(undefined as never);
await expect(
(service as any).finalizeFile(makeRow({ targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, new Map(), 1, true),
@@ -491,13 +504,42 @@ describe('BookDockFinalizeService', () => {
);
});
it('allows a stale indexed destination when the physical file is missing', async () => {
const { service, repo, 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/stale.epub' as never);
vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never);
vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never);
repo.findExistingBooksByAbsolutePaths.mockResolvedValue([{ absolutePath: '/library/stale.epub', bookId: 77, libraryId: 5 }]);
mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockStat.mockResolvedValueOnce({ size: 100 } as never);
processor.createBookRecord.mockResolvedValueOnce({ bookId: 77 });
const result = await (service as any).finalizeFile(
makeRow({ targetLibraryId: 5, targetFolderId: 9 }),
undefined,
undefined,
new Map(),
1,
true,
);
expect(result).toMatchObject({ success: true, bookId: 77 });
expect(result).not.toHaveProperty('isDuplicate');
});
it('rolls back moved files and reports failure when book record creation fails', async () => {
const { service, storage, processor } = makeService();
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } 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);
mockAccess.mockRejectedValueOnce(new Error('missing'));
mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockStat.mockResolvedValueOnce({ size: 321 } as never);
processor.createBookRecord.mockRejectedValueOnce(new Error('create failed'));
@@ -518,8 +560,7 @@ describe('BookDockFinalizeService', () => {
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } 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);
mockAccess.mockRejectedValueOnce(new Error('missing'));
mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockStat.mockResolvedValueOnce({ size: 321 } as never);
processor.createBookRecord.mockResolvedValueOnce({ bookId: 808 });
const constraintError = new Error('Failed query: update "book_metadata" set ...');
@@ -554,10 +595,9 @@ describe('BookDockFinalizeService', () => {
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } 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'));
mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockStat.mockResolvedValueOnce({ size: 100 } as never);
processor.createBookRecord.mockResolvedValueOnce({ bookId: 555 });
@@ -583,10 +623,9 @@ describe('BookDockFinalizeService', () => {
} 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'));
mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockStat.mockResolvedValueOnce({ size: 100 } as never);
processor.createBookRecord.mockResolvedValueOnce({ bookId: 556 });
@@ -596,122 +635,7 @@ describe('BookDockFinalizeService', () => {
expect(processor.createBookRecord).toHaveBeenCalledWith(5, 9, '/library/new/book.epub', '/library/new/book.epub', 'new/book.epub', 'epub', 100);
});
describe('skipDuplicateCheck override', () => {
it('bypasses duplicate detection and succeeds when skipDuplicateCheck is true', async () => {
const { service, processor } = makeService();
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({
id: 5,
allowedFormats: ['epub', 'pdf'],
fileNamingPattern: null,
organizationMode: 'book_per_folder',
} 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/Author/Title/Title.pdf' as never);
const findDuplicateSpy = vi.spyOn(service as never, 'findDuplicate');
vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never);
vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never);
mockAccess.mockRejectedValueOnce(new Error('ENOENT'));
mockStat.mockResolvedValueOnce({ size: 512 } as never);
processor.createBookRecord.mockResolvedValueOnce({ bookId: 200 });
const overrideMap = new Map([[1, { libraryId: 5, folderId: 9, skipDuplicateCheck: true }]]);
const result = await (service as any).finalizeFile(
makeRow({ id: 1, fileName: 'Title.pdf', format: 'pdf', targetLibraryId: 5, targetFolderId: 9 }),
undefined,
undefined,
overrideMap,
1,
true,
);
expect(result).toMatchObject({ success: true, bookId: 200 });
expect(findDuplicateSpy).not.toHaveBeenCalled();
});
it('still blocks import when destination file already exists even with skipDuplicateCheck', async () => {
const { service } = makeService();
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({
id: 5,
allowedFormats: ['epub'],
fileNamingPattern: null,
organizationMode: 'book_per_folder',
} 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/Author/Title/Title.epub' as never);
mockAccess.mockResolvedValueOnce(undefined as never);
const overrideMap = new Map([[1, { libraryId: 5, folderId: 9, skipDuplicateCheck: true }]]);
const result = await (service as any).finalizeFile(
makeRow({ id: 1, targetLibraryId: 5, targetFolderId: 9 }),
undefined,
undefined,
overrideMap,
1,
true,
);
expect(result).toMatchObject({
fileId: 1,
fileName: 'book.epub',
success: false,
message: 'A file with this name already exists at the target location',
});
});
it('calls findDuplicate normally when skipDuplicateCheck is absent', async () => {
const { service } = makeService();
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({
id: 5,
allowedFormats: ['epub'],
fileNamingPattern: null,
organizationMode: 'book_per_folder',
} 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/Author/Title/Title.epub' as never);
mockAccess.mockRejectedValueOnce(new Error('ENOENT'));
const findDuplicateSpy = vi.spyOn(service as never, 'findDuplicate').mockResolvedValue(77 as never);
const overrideMap = new Map([[1, { libraryId: 5, folderId: 9 }]]);
const result = await (service as any).finalizeFile(
makeRow({ id: 1, targetLibraryId: 5, targetFolderId: 9 }),
undefined,
undefined,
overrideMap,
1,
true,
);
expect(findDuplicateSpy).toHaveBeenCalledOnce();
expect(result).toMatchObject({ success: false, isDuplicate: true, existingBookId: 77 });
});
it('calls findDuplicate normally when skipDuplicateCheck is false', async () => {
const { service } = makeService();
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({
id: 5,
allowedFormats: ['epub'],
fileNamingPattern: null,
organizationMode: 'book_per_folder',
} 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/Author/Title/Title.epub' as never);
mockAccess.mockRejectedValueOnce(new Error('ENOENT'));
const findDuplicateSpy = vi.spyOn(service as never, 'findDuplicate').mockResolvedValue(88 as never);
const overrideMap = new Map([[1, { libraryId: 5, folderId: 9, skipDuplicateCheck: false }]]);
const result = await (service as any).finalizeFile(
makeRow({ id: 1, targetLibraryId: 5, targetFolderId: 9 }),
undefined,
undefined,
overrideMap,
1,
true,
);
expect(findDuplicateSpy).toHaveBeenCalledOnce();
expect(result).toMatchObject({ success: false, isDuplicate: true, existingBookId: 88 });
});
describe('organization modes', () => {
it('attaches pdf to existing book folder in book_per_folder mode', async () => {
const { service, processor } = makeService();
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({
@@ -725,11 +649,11 @@ describe('BookDockFinalizeService', () => {
vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/Author/Dune/Dune.pdf' 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('ENOENT'));
mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockStat.mockResolvedValueOnce({ size: 1024 } as never);
processor.createBookRecord.mockResolvedValueOnce({ bookId: 42 });
const overrideMap = new Map([[7, { libraryId: 5, folderId: 9, skipDuplicateCheck: true }]]);
const overrideMap = new Map([[7, { libraryId: 5, folderId: 9 }]]);
const result = await (service as any).finalizeFile(
makeRow({ id: 7, fileName: 'Dune.pdf', format: 'pdf', absolutePath: '/tmp/Dune.pdf', targetLibraryId: 5, targetFolderId: 9 }),
undefined,
@@ -752,7 +676,7 @@ describe('BookDockFinalizeService', () => {
);
});
it('creates a separate book record in book_per_file mode with skipDuplicateCheck', async () => {
it('creates a separate book record in book_per_file mode', async () => {
const { service, processor } = makeService();
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({
id: 6,
@@ -764,11 +688,11 @@ describe('BookDockFinalizeService', () => {
vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library2/Dune.pdf' 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('ENOENT'));
mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockStat.mockResolvedValueOnce({ size: 2048 } as never);
processor.createBookRecord.mockResolvedValueOnce({ bookId: 300 });
const overrideMap = new Map([[8, { libraryId: 6, folderId: 10, skipDuplicateCheck: true }]]);
const overrideMap = new Map([[8, { libraryId: 6, folderId: 10 }]]);
const result = await (service as any).finalizeFile(
makeRow({ id: 8, fileName: 'Dune.pdf', format: 'pdf', absolutePath: '/tmp/Dune.pdf', targetLibraryId: 6, targetFolderId: 10 }),
undefined,
@@ -782,24 +706,6 @@ describe('BookDockFinalizeService', () => {
// book_per_file: folderPath = destPath itself (each file is its own book)
expect(processor.createBookRecord).toHaveBeenCalledWith(6, 10, '/library2/Dune.pdf', '/library2/Dune.pdf', 'Dune.pdf', 'pdf', 2048);
});
it('passes skipDuplicateCheck through the finalize public API via overrides array', async () => {
const { service, repo } = makeService();
repo.findByIds.mockResolvedValue([makeRow({ id: 3, targetLibraryId: 5, targetFolderId: 9 })]);
vi.spyOn(service as never, 'buildDuplicateLookup').mockResolvedValue(new Map() as never);
const finalizeFileSpy = vi.spyOn(service as never, 'finalizeFile').mockResolvedValue({
fileId: 3,
fileName: 'book.pdf',
success: true,
bookId: 99,
} as never);
vi.spyOn(service as never, 'emitSummary').mockResolvedValue(undefined as never);
await service.finalize(1, true, [3], false, [], 5, 9, [{ fileId: 3, skipDuplicateCheck: true }]);
const overrideMapPassed = finalizeFileSpy.mock.calls[0]?.[3] as Map<number, unknown>;
expect(overrideMapPassed.get(3)).toMatchObject({ fileId: 3, skipDuplicateCheck: true });
});
});
});
@@ -814,10 +720,9 @@ describe('BookDockFinalizeService', () => {
} 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/Unknown Author/1/1.epub' as never);
const findDuplicateSpy = 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('ENOENT'));
mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockStat.mockResolvedValueOnce({ size: 993 } as never);
processor.createBookRecord.mockResolvedValueOnce({ bookId: 500 });
@@ -842,11 +747,10 @@ describe('BookDockFinalizeService', () => {
'epub',
993,
);
expect(findDuplicateSpy).toHaveBeenCalledOnce();
});
it('still blocks duplicate books when targetFileName is provided', async () => {
const { service } = makeService();
it('classifies an occupied indexed renamed destination as a duplicate', async () => {
const { service, repo } = makeService();
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({
id: 5,
allowedFormats: ['epub'],
@@ -855,7 +759,8 @@ describe('BookDockFinalizeService', () => {
} 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/Unknown Author/1/1.epub' as never);
vi.spyOn(service as never, 'findDuplicate').mockResolvedValue(77 as never);
repo.findExistingBooksByAbsolutePaths.mockResolvedValue([{ absolutePath: '/library/Unknown Author/1/1_alt.epub', bookId: 77, libraryId: 5 }]);
mockAccess.mockResolvedValueOnce(undefined as never);
const overrideMap = new Map([[1, { libraryId: 5, folderId: 9, targetFileName: '1_alt' }]]);
const result = await (service as any).finalizeFile(
@@ -872,7 +777,7 @@ describe('BookDockFinalizeService', () => {
isDuplicate: true,
existingBookId: 77,
});
expect(mockAccess).not.toHaveBeenCalled();
expect(repo.findExistingBooksByAbsolutePaths).toHaveBeenCalledWith(['/library/Unknown Author/1/1_alt.epub']);
});
it('targetFileName still fails when the renamed dest also already exists', async () => {
@@ -885,7 +790,6 @@ describe('BookDockFinalizeService', () => {
} 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/Unknown Author/1/1.epub' as never);
vi.spyOn(service as never, 'findDuplicate').mockResolvedValue(null as never);
// 1_alt.epub also already exists
mockAccess.mockResolvedValueOnce(undefined as never);
@@ -910,8 +814,12 @@ describe('BookDockFinalizeService', () => {
it('passes targetFileName through the finalize public API via overrides array', async () => {
const { service, repo } = makeService();
repo.findByIds.mockResolvedValue([makeRow({ id: 4, targetLibraryId: 5, targetFolderId: 9 })]);
vi.spyOn(service as never, 'buildDuplicateLookup').mockResolvedValue(new Map() as never);
const finalizeFileSpy = vi.spyOn(service as never, 'finalizeFile').mockResolvedValue({
const row = makeRow({ id: 4, targetLibraryId: 5, targetFolderId: 9 });
const prepareSpy = vi.spyOn(service as never, 'prepareFinalizeBatch').mockResolvedValue({
analyses: [{ fileId: 4, fileName: '1.epub', row, status: 'ready' }],
existingDestinations: new Map(),
} as never);
vi.spyOn(service as never, 'finalizePreparedCandidate').mockResolvedValue({
fileId: 4,
fileName: '1.epub',
success: true,
@@ -921,7 +829,7 @@ describe('BookDockFinalizeService', () => {
await service.finalize(1, true, [4], false, [], 5, 9, [{ fileId: 4, targetFileName: '1_alt' }]);
const overrideMapPassed = finalizeFileSpy.mock.calls[0]?.[3] as Map<number, unknown>;
const overrideMapPassed = prepareSpy.mock.calls[0]?.[3] as Map<number, unknown>;
expect(overrideMapPassed.get(4)).toMatchObject({ fileId: 4, targetFileName: '1_alt' });
});
@@ -935,10 +843,9 @@ describe('BookDockFinalizeService', () => {
} 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/Author/Title/Title.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('ENOENT'));
mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockStat.mockResolvedValueOnce({ size: 200 } as never);
processor.createBookRecord.mockResolvedValueOnce({ bookId: 600 });
@@ -1005,19 +912,17 @@ describe('BookDockFinalizeService', () => {
const conflictRow = makeRow({ id: 2, fileName: 'conflict.epub', targetLibraryId: 5, targetFolderId: 9 });
const readyRow = makeRow({ id: 3, fileName: 'ready.epub', targetLibraryId: 5, targetFolderId: 9 });
repo.findByIds.mockResolvedValue([duplicateRow, conflictRow, readyRow]);
vi.spyOn(service as never, 'buildDuplicateLookup').mockResolvedValue(new Map() as never);
repo.findExistingBooksByAbsolutePaths.mockResolvedValue([{ absolutePath: '/library/duplicate.epub', bookId: 77, libraryId: 5 }]);
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } as never);
vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never);
vi.spyOn(service as never, 'resolveDestination').mockImplementation((_, __, row: { fileName: string }) =>
Promise.resolve(`/library/${row.fileName}`),
);
vi.spyOn(service as never, 'findDuplicate')
.mockResolvedValueOnce(77 as never)
.mockResolvedValueOnce(null as never)
.mockResolvedValueOnce(null as never);
mockAccess.mockImplementation((path) => {
if (String(path).includes('conflict.epub')) return Promise.resolve(undefined) as never;
return Promise.reject(new Error('ENOENT')) as never;
if (String(path).includes('ready.epub')) {
return Promise.reject(Object.assign(new Error('not found'), { code: 'ENOENT' })) as never;
}
return Promise.resolve(undefined) as never;
});
const preview = await service.previewFinalize(1, true, [1, 2, 3], false, [], undefined, undefined);
@@ -1037,6 +942,12 @@ describe('BookDockFinalizeService', () => {
expect.objectContaining({ fileId: 3, status: 'ready' }),
]),
);
expect(repo.findExistingBooksByAbsolutePaths).toHaveBeenCalledTimes(1);
expect(repo.findExistingBooksByAbsolutePaths).toHaveBeenCalledWith([
'/library/duplicate.epub',
'/library/conflict.epub',
'/library/ready.epub',
]);
});
it('discards only duplicate candidates and cleans their files', async () => {
@@ -1052,19 +963,17 @@ describe('BookDockFinalizeService', () => {
const readyRow = makeRow({ id: 3, fileName: 'ready.epub', absolutePath: '/dock/ready.epub', targetLibraryId: 5, targetFolderId: 9 });
repo.findByIds.mockResolvedValue([duplicateRow, conflictRow, readyRow]);
repo.countsByStatus.mockResolvedValue({ pending: 0, ready: 2, error: 0, total: 2 });
vi.spyOn(service as never, 'buildDuplicateLookup').mockResolvedValue(new Map() as never);
repo.findExistingBooksByAbsolutePaths.mockResolvedValue([{ absolutePath: '/library/duplicate.epub', bookId: 77, libraryId: 5 }]);
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } as never);
vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never);
vi.spyOn(service as never, 'resolveDestination').mockImplementation((_, __, row: { fileName: string }) =>
Promise.resolve(`/library/${row.fileName}`),
);
vi.spyOn(service as never, 'findDuplicate')
.mockResolvedValueOnce(77 as never)
.mockResolvedValueOnce(null as never)
.mockResolvedValueOnce(null as never);
mockAccess.mockImplementation((path) => {
if (String(path).includes('conflict.epub')) return Promise.resolve(undefined) as never;
return Promise.reject(new Error('ENOENT')) as never;
if (String(path).includes('ready.epub')) {
return Promise.reject(Object.assign(new Error('not found'), { code: 'ENOENT' })) as never;
}
return Promise.resolve(undefined) as never;
});
const result = await service.discardDuplicateCandidates(1, true, [1, 2, 3], false, [], undefined, undefined);
@@ -1566,101 +1475,67 @@ describe('BookDockFinalizeService', () => {
await expect((service as any).findFolderOrFail(9, 5)).rejects.toBeInstanceOf(BadRequestException);
});
it('buildDuplicateLookup collects isbn and title+author keys per library', async () => {
const { service, db } = makeService();
const selectChain = {
from: vi.fn(),
innerJoin: vi.fn(),
where: vi.fn(),
};
selectChain.from.mockReturnValue(selectChain);
selectChain.innerJoin.mockReturnValue(selectChain);
selectChain.where
.mockResolvedValueOnce([{ bookId: 11, isbn13: '9780306406157' }])
.mockResolvedValueOnce([{ bookId: 12, isbn10: '0306406152' }])
.mockResolvedValueOnce([{ bookId: 13, normalizedTitle: 'dune', normalizedAuthor: 'frank herbert' }]);
db.select.mockReturnValue(selectChain);
const lookup = await (service as any).buildDuplicateLookup(
[
makeRow({ id: 1, targetLibraryId: 5, selectedMetadata: { isbn13: '9780306406157' } }),
makeRow({ id: 2, targetLibraryId: 5, selectedMetadata: { isbn10: '0306406152' } }),
makeRow({ id: 3, targetLibraryId: 5, selectedMetadata: { title: 'Dune', authors: ['Frank Herbert'] } }),
],
undefined,
new Map(),
it('prepares one batched destination lookup and ignores matching metadata', async () => {
const { service, repo } = makeService();
const rows = [
makeRow({ id: 1, fileName: 'one.epub', selectedMetadata: { title: 'Same', isbn13: '9780306406157' } }),
makeRow({ id: 2, fileName: 'two.epub', selectedMetadata: { title: 'Same', isbn13: '9780306406157' } }),
];
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({
id: 5,
allowedFormats: ['epub'],
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').mockImplementation((_, __, row: { fileName: string }) =>
Promise.resolve(`/library/${row.fileName}`),
);
expect(lookup.get('library:5|isbn13:9780306406157')).toBe(11);
expect(lookup.get('library:5|isbn10:0306406152')).toBe(12);
expect(lookup.get('library:5|title:dune|author:frank herbert')).toBe(13);
const prepared = await (service as any).prepareFinalizeBatch(rows, 5, 9, new Map(), 1, true);
expect(prepared.analyses.map((analysis: { status: string }) => analysis.status)).toEqual(['ready', 'ready']);
expect(repo.findExistingBooksByAbsolutePaths).toHaveBeenCalledOnce();
expect(repo.findExistingBooksByAbsolutePaths).toHaveBeenCalledWith(['/library/one.epub', '/library/two.epub']);
});
it('buildDuplicateLookup keeps only requested title+author pairs', async () => {
const { service, db } = makeService();
const selectChain = {
from: vi.fn(),
innerJoin: vi.fn(),
where: vi.fn(),
it('scopes indexed destination matches to the target library', async () => {
const { service } = makeService();
const analysis = {
fileId: 1,
fileName: 'book.epub',
row: makeRow(),
status: 'ready',
destPath: '/shared/book.epub',
library: { id: 5 },
folder: { id: 9, path: '/shared' },
format: 'epub',
};
selectChain.from.mockReturnValue(selectChain);
selectChain.innerJoin.mockReturnValue(selectChain);
selectChain.where.mockResolvedValueOnce([
{ bookId: 13, normalizedTitle: 'dune', normalizedAuthor: 'frank herbert' },
{ bookId: 14, normalizedTitle: 'dune', normalizedAuthor: 'isaac asimov' },
]);
db.select.mockReturnValue(selectChain);
mockAccess.mockResolvedValueOnce(undefined as never);
const lookup = await (service as any).buildDuplicateLookup(
[
makeRow({ id: 1, targetLibraryId: 5, selectedMetadata: { title: 'Dune', authors: ['Frank Herbert'] } }),
makeRow({ id: 2, targetLibraryId: 5, selectedMetadata: { title: 'Foundation', authors: ['Isaac Asimov'] } }),
],
undefined,
new Map(),
);
const classified = await (service as any).classifyDestination(analysis, new Map([['6\u0000/shared/book.epub', 77]]));
expect(lookup.get('library:5|title:dune|author:frank herbert')).toBe(13);
expect(lookup.get('library:5|title:dune|author:isaac asimov')).toBeUndefined();
expect(classified).toMatchObject({ status: 'destination_conflict' });
expect(classified).not.toHaveProperty('existingBookId');
});
it('findDuplicate queries by isbn and title+author when prebuilt lookup misses', async () => {
const { service, db } = makeService();
const selectChain = {
from: vi.fn(),
innerJoin: vi.fn(),
where: vi.fn(),
limit: vi.fn(),
it('reports non-ENOENT destination access failures without moving the file', async () => {
const { service, storage } = makeService();
const analysis = {
fileId: 1,
fileName: 'book.epub',
row: makeRow(),
status: 'ready',
destPath: '/library/book.epub',
library: { id: 5 },
folder: { id: 9, path: '/library' },
format: 'epub',
};
selectChain.from.mockReturnValue(selectChain);
selectChain.innerJoin.mockReturnValue(selectChain);
selectChain.where.mockReturnValue(selectChain);
selectChain.limit.mockResolvedValueOnce([{ bookId: 91 }]).mockResolvedValueOnce([{ bookId: 92 }]);
db.select.mockReturnValue(selectChain);
mockAccess.mockRejectedValueOnce(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
await expect((service as any).findDuplicate(4, { isbn13: '9780306406157', isbn10: null, title: null, authors: [] })).resolves.toBe(91);
await expect((service as any).findDuplicate(4, { isbn13: null, isbn10: null, title: 'Dune', authors: ['Frank Herbert'] })).resolves.toBe(92);
await expect((service as any).findDuplicate(4, { isbn13: null, isbn10: null, title: 'Dune', authors: [] })).resolves.toBeNull();
});
const classified = await (service as any).classifyDestination(analysis, new Map());
it('findDuplicate does not treat title-only prebuilt entries as duplicates when author differs', async () => {
const { service, db } = makeService();
const selectChain = {
from: vi.fn(),
innerJoin: vi.fn(),
where: vi.fn(),
limit: vi.fn(),
};
selectChain.from.mockReturnValue(selectChain);
selectChain.innerJoin.mockReturnValue(selectChain);
selectChain.where.mockReturnValue(selectChain);
selectChain.limit.mockResolvedValueOnce([]);
db.select.mockReturnValue(selectChain);
const duplicateLookup = new Map([['library:4|title:dune', 77]]);
await expect(
(service as any).findDuplicate(4, { isbn13: null, isbn10: null, title: 'Dune', authors: ['Brian Herbert'] }, duplicateLookup),
).resolves.toBeNull();
expect(classified).toMatchObject({ status: 'error', message: 'permission denied' });
expect(storage.moveToPath).not.toHaveBeenCalled();
});
it('resolveDestination builds names from patterns and falls back to original filename', async () => {
@@ -1675,7 +1550,7 @@ describe('BookDockFinalizeService', () => {
'/library/Dune-02.5.epub',
);
await expect((service as any).resolveDestination({ fileNamingPattern: null }, '/library', rowWithMeta, 'epub')).resolves.toBe(
'/library/original.epub',
'/library/original/original.epub',
);
});
@@ -1725,24 +1600,6 @@ describe('BookDockFinalizeService', () => {
);
});
it('findDuplicate resolves from prebuilt lookup before querying database', async () => {
const { service, db } = makeService();
const duplicateLookup = new Map([['library:4|isbn13:9780306406157', 88]]);
await expect((service as any).findDuplicate(4, { isbn13: '9780306406157', isbn10: null, title: null }, duplicateLookup)).resolves.toBe(88);
expect(db.select).not.toHaveBeenCalled();
});
it('findDuplicate resolves title+author from prebuilt lookup before querying database', async () => {
const { service, db } = makeService();
const duplicateLookup = new Map([['library:4|title:dune|author:frank herbert', 89]]);
await expect(
(service as any).findDuplicate(4, { isbn13: null, isbn10: null, title: 'Dune', authors: ['Frank Herbert'] }, duplicateLookup),
).resolves.toBe(89);
expect(db.select).not.toHaveBeenCalled();
});
it('triggerAutoFinalize skips when auto-finalize is disabled or destination is incomplete', async () => {
const { service, appSettings, repo } = makeService();
appSettings.getAutoFinalizeSettings.mockResolvedValueOnce({
@@ -12,7 +12,7 @@ import {
} from '@nestjs/common';
import { basename, dirname, extname, join, resolve } from 'path';
import { access as fsAccess, readFile, stat, unlink } from 'fs/promises';
import { and, eq, inArray, or, sql } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type {
@@ -28,7 +28,7 @@ import type {
ComicMetadataFields,
MetadataSeriesMembership,
} from '@bookorbit/types';
import { MetadataProviderKey, NotificationType, resolveUploadPath } from '@bookorbit/types';
import { MetadataProviderKey, NotificationType, resolveDownloadFilename, resolveUploadPath } from '@bookorbit/types';
import { BookReadService } from '../book/book-read.service';
import { NotificationService } from '../notification/notification.service';
import { SeriesIdentityService } from '../../common/services/series-identity.service';
@@ -38,7 +38,7 @@ import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/u
import { formatSeriesIndex } from '../../common/utils/series-index-format.utils';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookMetadata, books, libraries, libraryFolders } from '../../db/schema';
import { bookMetadata, libraries, libraryFolders } from '../../db/schema';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { LibraryService } from '../library/library.service';
import { MetadataService } from '../metadata/metadata.service';
@@ -60,7 +60,6 @@ type LibraryFolderRow = typeof libraryFolders.$inferSelect;
type FinalizeOverrideEntry = {
libraryId?: number;
folderId?: number;
skipDuplicateCheck?: boolean;
targetFileName?: string;
};
@@ -201,9 +200,9 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
});
if (rows.length === 0) break;
const duplicateLookup = await this.buildDuplicateLookup(rows, defaultLibraryId, overrideMap);
for (const row of rows) {
const result = await this.finalizeFile(row, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser, duplicateLookup);
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
for (const analysis of prepared.analyses) {
const result = await this.finalizePreparedCandidate(analysis, prepared.existingDestinations);
results.push(result);
if (result.success) succeeded++;
else failed++;
@@ -215,12 +214,12 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
const batch = ids.slice(i, i + BATCH_SIZE);
const rows = await this.repo.findByIds(batch, userId, isSuperuser);
const rowById = new Map(rows.map((row) => [row.id, row]));
const duplicateLookup = await this.buildDuplicateLookup(rows, defaultLibraryId, overrideMap);
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
const analysisById = new Map(prepared.analyses.map((analysis) => [analysis.fileId, analysis]));
for (const fileId of batch) {
const row = rowById.get(fileId);
if (!row) {
const analysis = analysisById.get(fileId);
if (!analysis) {
failed++;
results.push({
fileId,
@@ -231,7 +230,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
continue;
}
const result = await this.finalizeFile(row, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser, duplicateLookup);
const result = await this.finalizePreparedCandidate(analysis, prepared.existingDestinations);
results.push(result);
if (result.success) succeeded++;
else failed++;
@@ -261,10 +260,22 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
overrideMap: Map<number, FinalizeOverrideEntry>,
userId: number,
isSuperuser: boolean,
duplicateLookup?: Map<string, number>,
): Promise<BookDockFinalizeFileResult> {
const prepared = await this.prepareFinalizeBatch([row], defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
const analysis = prepared.analyses[0];
if (!analysis) {
return { fileId: row.id, fileName: row.fileName, success: false, message: 'Finalization target could not be resolved' };
}
return this.finalizePreparedCandidate(analysis, prepared.existingDestinations);
}
private async finalizePreparedCandidate(
preparedAnalysis: FinalizeCandidateAnalysis,
existingDestinations: Map<string, number>,
): Promise<BookDockFinalizeFileResult> {
const row = preparedAnalysis.row;
try {
const analysis = await this.analyzeFinalizeCandidate(row, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser, duplicateLookup);
const analysis = await this.classifyDestination(preparedAnalysis, existingDestinations);
if (analysis.status !== 'ready') return this.analysisToFileResult(analysis);
const { destPath, folder, library, format } = analysis;
@@ -294,6 +305,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
}
await this.cleanupBookDockRecord(row);
existingDestinations.set(this.destinationKey(library.id, destPath), bookId);
const newName = destPath.substring(folder.path.length + 1);
return { fileId: row.id, fileName: row.fileName, newName, success: true, bookId };
@@ -320,17 +332,9 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
const overrideMap = new Map((overrides ?? []).map((o) => [o.fileId, o]));
await this.processFinalizeSelection(userId, isSuperuser, fileIds, selectAll, excludedIds, status, search, async (rows, missingIds) => {
const duplicateLookup = await this.buildDuplicateLookup(rows, defaultLibraryId, overrideMap);
for (const row of rows) {
const analysis = await this.analyzeFinalizeCandidate(
row,
defaultLibraryId,
defaultFolderId,
overrideMap,
userId,
isSuperuser,
duplicateLookup,
);
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
for (const candidate of prepared.analyses) {
const analysis = await this.classifyDestination(candidate, prepared.existingDestinations);
addFinalizePreviewAnalysis(summary, analysis);
}
for (const fileId of missingIds) {
@@ -369,20 +373,12 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
try {
await this.processFinalizeSelection(userId, isSuperuser, fileIds, selectAll, excludedIds, status, search, async (rows, missingIds) => {
total += rows.length + missingIds.length;
const duplicateLookup = await this.buildDuplicateLookup(rows, defaultLibraryId, overrideMap);
const prepared = await this.prepareFinalizeBatch(rows, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser);
const duplicateRows: BookDockFileRow[] = [];
for (const row of rows) {
const analysis = await this.analyzeFinalizeCandidate(
row,
defaultLibraryId,
defaultFolderId,
overrideMap,
userId,
isSuperuser,
duplicateLookup,
);
if (analysis.status === 'duplicate') duplicateRows.push(row);
for (const candidate of prepared.analyses) {
const analysis = await this.classifyDestination(candidate, prepared.existingDestinations);
if (analysis.status === 'duplicate') duplicateRows.push(candidate.row);
}
if (duplicateRows.length === 0) return;
@@ -411,14 +407,13 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
}
}
private async analyzeFinalizeCandidate(
private async resolveFinalizeCandidate(
row: BookDockFileRow,
defaultLibraryId: number | undefined,
defaultFolderId: number | undefined,
overrideMap: Map<number, FinalizeOverrideEntry>,
userId: number,
isSuperuser: boolean,
duplicateLookup?: Map<string, number>,
): Promise<FinalizeCandidateAnalysis> {
try {
const override = overrideMap.get(row.id);
@@ -456,36 +451,6 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
const newName = destPath.substring(folder.path.length + 1);
if (!override?.skipDuplicateCheck) {
const meta = normalizeFinalizeMetadata(row.selectedMetadata ?? row.embeddedMetadata ?? {});
const existingBookId = await this.findDuplicate(libraryId, meta, duplicateLookup);
if (existingBookId !== null) {
return {
fileId: row.id,
fileName: row.fileName,
row,
status: 'duplicate',
existingBookId,
newName,
message: 'Duplicate: this book already exists in the library',
};
}
}
const exists = await fsAccess(destPath)
.then(() => true)
.catch(() => false);
if (exists) {
return {
fileId: row.id,
fileName: row.fileName,
row,
status: 'destination_conflict',
newName,
message: 'A file with this name already exists at the target location',
};
}
return { fileId: row.id, fileName: row.fileName, row, status: 'ready', newName, library, folder, format, destPath };
} catch (error) {
return {
@@ -498,6 +463,66 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
}
}
private async prepareFinalizeBatch(
rows: BookDockFileRow[],
defaultLibraryId: number | undefined,
defaultFolderId: number | undefined,
overrideMap: Map<number, FinalizeOverrideEntry>,
userId: number,
isSuperuser: boolean,
): Promise<{ analyses: FinalizeCandidateAnalysis[]; existingDestinations: Map<string, number> }> {
const analyses: FinalizeCandidateAnalysis[] = [];
for (const row of rows) {
analyses.push(await this.resolveFinalizeCandidate(row, defaultLibraryId, defaultFolderId, overrideMap, userId, isSuperuser));
}
const destinationPaths = analyses.filter((analysis) => analysis.status === 'ready' && analysis.destPath).map((analysis) => analysis.destPath!);
const existingRows = await this.repo.findExistingBooksByAbsolutePaths(destinationPaths);
const existingDestinations = new Map(
existingRows.map((existing) => [this.destinationKey(existing.libraryId, existing.absolutePath), existing.bookId]),
);
return { analyses, existingDestinations };
}
private async classifyDestination(
analysis: FinalizeCandidateAnalysis,
existingDestinations: Map<string, number>,
): Promise<FinalizeCandidateAnalysis> {
if (analysis.status !== 'ready' || !analysis.destPath || !analysis.library) return analysis;
try {
await fsAccess(analysis.destPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return analysis;
return {
...analysis,
status: 'error',
message: resolveFinalizeErrorMessage(error),
};
}
const existingBookId = existingDestinations.get(this.destinationKey(analysis.library.id, analysis.destPath));
if (existingBookId !== undefined) {
return {
...analysis,
status: 'duplicate',
existingBookId,
message: 'A file with this name already exists at the target location',
};
}
return {
...analysis,
status: 'destination_conflict',
message: 'A file with this name already exists at the target location',
};
}
private destinationKey(libraryId: number, absolutePath: string): string {
return `${libraryId}\u0000${absolutePath}`;
}
private analysisToFileResult(analysis: FinalizeCandidateAnalysis): BookDockFinalizeFileResult {
if (analysis.status === 'duplicate') {
return {
@@ -676,16 +701,19 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
return rows.map((row) => {
const format = row.format ?? extname(row.fileName).toLowerCase().slice(1);
const meta = row.selectedMetadata ?? row.embeddedMetadata ?? {};
let newName = row.fileName;
const effectiveLibraryId = row.targetLibraryId ?? defaultLibraryId ?? null;
const lib = effectiveLibraryId !== null ? libraryMap.get(effectiveLibraryId) : undefined;
let newName = lib?.organizationMode === 'book_per_folder' ? join(basename(row.fileName, extname(row.fileName)), row.fileName) : row.fileName;
const libraryPattern = lib?.fileNamingPattern ?? null;
const appPattern = lib?.organizationMode === 'book_per_folder' ? appPatternFolder : appPatternFile;
const pattern = libraryPattern ?? appPattern;
if (pattern) {
const tokens = this.buildPatternTokens(meta, row.fileName, format);
const resolved = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
const resolved =
lib?.organizationMode === 'book_per_file'
? resolveDownloadFilename(pattern, tokens, format, { sanitizeForCrossPlatform })
: resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
if (resolved) newName = resolved;
}
@@ -693,190 +721,6 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
});
}
private async buildDuplicateLookup(
rows: BookDockFileRow[],
defaultLibraryId: number | undefined,
overrideMap: Map<number, FinalizeOverrideEntry>,
): Promise<Map<string, number>> {
const needsByLibrary = new Map<
number,
{ isbn13: Set<string>; isbn10: Set<string>; titles: Set<string>; authors: Set<string>; titleAuthorPairs: Set<string> }
>();
for (const row of rows) {
const override = overrideMap.get(row.id);
const libraryId = override?.libraryId ?? row.targetLibraryId ?? defaultLibraryId ?? null;
if (libraryId === null) continue;
const meta = normalizeFinalizeMetadata(row.selectedMetadata ?? row.embeddedMetadata ?? {});
let bucket = needsByLibrary.get(libraryId);
if (!bucket) {
bucket = { isbn13: new Set(), isbn10: new Set(), titles: new Set(), authors: new Set(), titleAuthorPairs: new Set() };
needsByLibrary.set(libraryId, bucket);
}
if (meta.isbn13) {
bucket.isbn13.add(meta.isbn13);
} else if (meta.isbn10) {
bucket.isbn10.add(meta.isbn10);
} else if (meta.title) {
const normalizedAuthors = normalizeDuplicateAuthors(meta.authors);
if (normalizedAuthors.length === 0) continue;
bucket.titles.add(meta.title.toLowerCase());
for (const authorName of normalizedAuthors) {
bucket.authors.add(authorName);
bucket.titleAuthorPairs.add(`${meta.title.toLowerCase()}|${authorName}`);
}
}
}
const lookup = new Map<string, number>();
for (const [libraryId, values] of needsByLibrary) {
if (values.isbn13.size > 0) {
const rowsByIsbn13 = await this.db
.select({ bookId: bookMetadata.bookId, isbn13: bookMetadata.isbn13 })
.from(bookMetadata)
.innerJoin(books, eq(books.id, bookMetadata.bookId))
.where(and(eq(books.libraryId, libraryId), inArray(bookMetadata.isbn13, [...values.isbn13])));
for (const row of rowsByIsbn13) {
if (!row.isbn13) continue;
const key = this.buildDuplicateLookupKey(libraryId, { isbn13: row.isbn13, isbn10: null, title: null });
if (key && !lookup.has(key)) {
lookup.set(key, row.bookId);
}
}
}
if (values.isbn10.size > 0) {
const rowsByIsbn10 = await this.db
.select({ bookId: bookMetadata.bookId, isbn10: bookMetadata.isbn10 })
.from(bookMetadata)
.innerJoin(books, eq(books.id, bookMetadata.bookId))
.where(and(eq(books.libraryId, libraryId), inArray(bookMetadata.isbn10, [...values.isbn10])));
for (const row of rowsByIsbn10) {
if (!row.isbn10) continue;
const key = this.buildDuplicateLookupKey(libraryId, { isbn13: null, isbn10: row.isbn10, title: null });
if (key && !lookup.has(key)) {
lookup.set(key, row.bookId);
}
}
}
if (values.titles.size > 0 && values.authors.size > 0) {
const rowsByTitleAuthor = await this.db
.select({
bookId: bookMetadata.bookId,
normalizedTitle: sql<string>`lower(${bookMetadata.title})`,
normalizedAuthor: sql<string>`lower(${authors.name})`,
})
.from(bookMetadata)
.innerJoin(books, eq(books.id, bookMetadata.bookId))
.innerJoin(bookAuthors, eq(bookAuthors.bookId, bookMetadata.bookId))
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(
and(
eq(books.libraryId, libraryId),
inArray(sql<string>`lower(${bookMetadata.title})`, [...values.titles]),
inArray(sql<string>`lower(${authors.name})`, [...values.authors]),
),
);
for (const row of rowsByTitleAuthor) {
if (!row.normalizedTitle || !row.normalizedAuthor) continue;
const pairKey = `${row.normalizedTitle}|${row.normalizedAuthor}`;
if (!values.titleAuthorPairs.has(pairKey)) continue;
const key = this.buildDuplicateLookupKey(libraryId, {
isbn13: null,
isbn10: null,
title: row.normalizedTitle,
author: row.normalizedAuthor,
});
if (key && !lookup.has(key)) {
lookup.set(key, row.bookId);
}
}
}
}
return lookup;
}
private buildDuplicateLookupKey(
libraryId: number,
meta: { isbn13: string | null; isbn10: string | null; title: string | null; author?: string | null },
): string | null {
const isbn = meta.isbn13 ?? meta.isbn10;
if (isbn) {
const isbnKind = meta.isbn13 ? 'isbn13' : 'isbn10';
return `library:${libraryId}|${isbnKind}:${isbn}`;
}
if (meta.title && meta.author) {
return `library:${libraryId}|title:${meta.title.toLowerCase()}|author:${meta.author.toLowerCase()}`;
}
return null;
}
private async findDuplicate(
libraryId: number,
meta: Pick<NormalizedFinalizeMetadata, 'isbn13' | 'isbn10' | 'title' | 'authors'>,
duplicateLookup?: Map<string, number>,
): Promise<number | null> {
const isbn = meta.isbn13 ?? meta.isbn10;
if (isbn) {
const lookupKey = this.buildDuplicateLookupKey(libraryId, { isbn13: meta.isbn13, isbn10: meta.isbn10, title: null, author: null });
if (lookupKey && duplicateLookup?.has(lookupKey)) {
return duplicateLookup.get(lookupKey) ?? null;
}
const conditions = meta.isbn13 ? [eq(bookMetadata.isbn13, meta.isbn13)] : [eq(bookMetadata.isbn10, meta.isbn10!)];
const [existing] = await this.db
.select({ bookId: bookMetadata.bookId })
.from(bookMetadata)
.innerJoin(books, eq(books.id, bookMetadata.bookId))
.where(and(eq(books.libraryId, libraryId), or(...conditions)))
.limit(1);
if (existing) return existing.bookId;
}
if (!isbn && meta.title) {
const normalizedAuthors = normalizeDuplicateAuthors(meta.authors);
if (normalizedAuthors.length === 0) return null;
for (const authorName of normalizedAuthors) {
const lookupKey = this.buildDuplicateLookupKey(libraryId, {
isbn13: null,
isbn10: null,
title: meta.title,
author: authorName,
});
if (lookupKey && duplicateLookup?.has(lookupKey)) {
return duplicateLookup.get(lookupKey) ?? null;
}
}
const [existing] = await this.db
.select({ bookId: bookMetadata.bookId })
.from(bookMetadata)
.innerJoin(books, eq(books.id, bookMetadata.bookId))
.innerJoin(bookAuthors, eq(bookAuthors.bookId, bookMetadata.bookId))
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(
and(
eq(books.libraryId, libraryId),
sql`lower(${bookMetadata.title}) = lower(${meta.title})`,
inArray(sql<string>`lower(${authors.name})`, normalizedAuthors),
),
)
.limit(1);
if (existing) return existing.bookId;
}
return null;
}
private async resolveDestination(
library: { fileNamingPattern?: string | null; organizationMode?: string | null },
folderPath: string,
@@ -893,11 +737,17 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
if (pattern) {
const tokens = this.buildPatternTokens(meta, row.fileName, format);
const resolved = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
const resolved =
library.organizationMode === 'book_per_file'
? resolveDownloadFilename(pattern, tokens, format, { sanitizeForCrossPlatform })
: resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
if (resolved) return join(folderPath, resolved);
}
return join(folderPath, row.fileName);
if (library.organizationMode === 'book_per_file') return join(folderPath, row.fileName);
const stem = basename(row.fileName, extname(row.fileName));
return join(folderPath, stem, row.fileName);
}
private buildPatternTokens(meta: BookDockMetadata, fileName: string, format: string): Record<string, string> {
@@ -1162,18 +1012,6 @@ function normalizeStringArray(value: unknown, maxLength: number): string[] {
return normalized;
}
function normalizeDuplicateAuthors(value: string[] | null | undefined): string[] {
if (!Array.isArray(value)) return [];
const normalized = new Set<string>();
for (const author of value) {
if (typeof author !== 'string') continue;
const trimmed = author.trim().toLowerCase();
if (!trimmed) continue;
normalized.add(trimmed);
}
return [...normalized];
}
function normalizeFinalizeMetadata(meta: BookDockMetadata | null | undefined): NormalizedFinalizeMetadata {
const normalizedMeta = normalizeBookDockMetadata(meta);
const publishedDate = normalizePublishedDate(normalizedMeta?.publishedDate) ?? null;
@@ -24,6 +24,7 @@ import { BookDockRepository } from './book-dock.repository';
function makeDb() {
const selectBuilder = {
from: vi.fn(),
innerJoin: vi.fn(),
where: vi.fn(),
orderBy: vi.fn(),
limit: vi.fn(),
@@ -31,6 +32,7 @@ function makeDb() {
groupBy: vi.fn(),
};
selectBuilder.from.mockReturnValue(selectBuilder);
selectBuilder.innerJoin.mockReturnValue(selectBuilder);
selectBuilder.where.mockReturnValue(selectBuilder);
selectBuilder.orderBy.mockReturnValue(selectBuilder);
selectBuilder.offset.mockResolvedValue([]);
@@ -75,6 +77,28 @@ function makeDb() {
}
describe('BookDockRepository', () => {
it('findExistingBooksByAbsolutePaths short-circuits an empty path list', async () => {
const { db } = makeDb();
const repo = new BookDockRepository(db as never);
await expect(repo.findExistingBooksByAbsolutePaths([])).resolves.toEqual([]);
expect(db.select).not.toHaveBeenCalled();
});
it('findExistingBooksByAbsolutePaths deduplicates paths in one joined query', async () => {
const { db, selectBuilder } = makeDb();
const existing = [{ absolutePath: '/library/book.epub', bookId: 12, libraryId: 5 }];
selectBuilder.where.mockResolvedValueOnce(existing);
const repo = new BookDockRepository(db as never);
await expect(repo.findExistingBooksByAbsolutePaths(['/library/book.epub', '/library/book.epub'])).resolves.toEqual(existing);
expect(db.select).toHaveBeenCalledTimes(1);
expect(selectBuilder.innerJoin).toHaveBeenCalledTimes(1);
expect(selectBuilder.where).toHaveBeenCalledWith(expect.objectContaining({ op: 'inArray', right: ['/library/book.epub'] }));
});
it('findByIds/deleteByIds/setTargetsByIds short-circuit empty selections', async () => {
const { db } = makeDb();
const repo = new BookDockRepository(db as never);
@@ -5,7 +5,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
import * as schema from '../../db/schema';
import { bookDockFiles, type NewBookDockFileRow, type BookDockFileRow } from '../../db/schema';
import { bookDockFiles, bookFiles, books, type NewBookDockFileRow, type BookDockFileRow } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
@@ -118,6 +118,21 @@ export class BookDockRepository {
.where(and(...conditions));
}
async findExistingBooksByAbsolutePaths(absolutePaths: string[]): Promise<Array<{ absolutePath: string; bookId: number; libraryId: number }>> {
const paths = [...new Set(absolutePaths)];
if (paths.length === 0) return [];
return this.db
.select({
absolutePath: bookFiles.absolutePath,
bookId: bookFiles.bookId,
libraryId: books.libraryId,
})
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(inArray(bookFiles.absolutePath, paths));
}
async findSelectionBatch(options: SelectionBatchOptions): Promise<BookDockFileRow[]> {
const conditions = this.buildSelectionConditions(options.status, options.search, options.userId, options.isSuperuser);
if (options.excludedIds?.length) conditions.push(notInArray(bookDockFiles.id, options.excludedIds));
@@ -156,10 +156,6 @@ class FinalizeOverrideDto {
@IsInt()
folderId?: number;
@IsOptional()
@IsBoolean()
skipDuplicateCheck?: boolean;
@IsOptional()
@IsString()
@IsNotEmpty()
@@ -404,8 +404,8 @@ describe('Book Dock ingest + finalize (e2e)', () => {
const existingBookId = seedBody.results[0]!.bookId!;
const duplicateRow = await createBookDockRow(context, {
fileName: 'duplicate-input.fb2',
selectedMetadata: { title: 'Duplicate Candidate Title', authors: ['Another Author'], isbn13: '9780306406157' },
fileName: 'seed-duplicate.fb2',
selectedMetadata: { title: 'Duplicate Seed Title', authors: ['Duplicate Author'], isbn13: '9780306406157' },
targetLibraryId: destination.libraryId,
targetFolderId: destination.libraryFolderId,
});
@@ -491,8 +491,8 @@ describe('Book Dock ingest + finalize (e2e)', () => {
const existingBookId = seedBody.results[0]!.bookId!;
const duplicateRow = await createBookDockRow(context, {
fileName: 'preview-duplicate-input.fb2',
selectedMetadata: { title: 'Preview Duplicate Candidate', authors: ['Different Author'], isbn13: '9780306406157' },
fileName: 'preview-seed-duplicate.fb2',
selectedMetadata: { title: 'Preview Duplicate Seed', authors: ['Preview Author'], isbn13: '9780306406157' },
targetLibraryId: destination.libraryId,
targetFolderId: destination.libraryFolderId,
});
@@ -572,12 +572,12 @@ describe('Book Dock ingest + finalize (e2e)', () => {
expect(await fileExists(readyRow.absolutePath)).toBe(true);
});
it('finalize allows same title with different author when isbn is missing', async () => {
it('finalize allows the same ISBN when resolved destinations differ', async () => {
const destination = await createLibraryWithFolder(context);
const seedRow = await createBookDockRow(context, {
fileName: 'seed-same-title.fb2',
selectedMetadata: { title: 'Shared Title', authors: ['Author One'] },
fileName: 'same-isbn-first.fb2',
selectedMetadata: { title: 'Same ISBN First', authors: ['Author One'], isbn13: '9780306406157' },
targetLibraryId: destination.libraryId,
targetFolderId: destination.libraryFolderId,
});
@@ -593,8 +593,8 @@ describe('Book Dock ingest + finalize (e2e)', () => {
const existingBookId = seedBody.results[0]!.bookId!;
const candidateRow = await createBookDockRow(context, {
fileName: 'candidate-same-title.fb2',
selectedMetadata: { title: 'Shared Title', authors: ['Author Two'] },
fileName: 'same-isbn-second.fb2',
selectedMetadata: { title: 'Same ISBN Second', authors: ['Author Two'], isbn13: '9780306406157' },
targetLibraryId: destination.libraryId,
targetFolderId: destination.libraryFolderId,
});
@@ -881,6 +881,21 @@ describe('Book Dock ingest + finalize (e2e)', () => {
message: expect.arrayContaining(['defaultLibraryId and defaultFolderId must either both be provided or both be omitted']),
});
const bypassValidationResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/book-dock/finalize',
headers: authHeader(context.adminToken),
payload: {
fileIds: [123],
overrides: [{ fileId: 123, skipDuplicateCheck: true }],
},
});
expect(bypassValidationResponse.statusCode).toBe(400);
expect(bypassValidationResponse.json()).toMatchObject({
message: expect.arrayContaining(['overrides.0.property skipDuplicateCheck should not exist']),
});
const retriableErrorRow = await createBookDockRow(context, {
fileName: 'retry-fetch.fb2',
status: 'error',