diff --git a/client/src/features/book/composables/useBookDownload.ts b/client/src/features/book/composables/useBookDownload.ts index 9ba396a8a..ee50bc87a 100644 --- a/client/src/features/book/composables/useBookDownload.ts +++ b/client/src/features/book/composables/useBookDownload.ts @@ -3,14 +3,18 @@ import { toast } from 'vue-sonner' type ExportScope = 'primary' | 'all' | 'audio' -function triggerBrowserDownload(url: string, filename?: string): void { +async function triggerBrowserDownload(url: string, filename?: string): Promise { + const response = await fetch(url, { credentials: 'same-origin' }) + const blob = await response.blob() + const objectUrl = URL.createObjectURL(blob) const anchor = document.createElement('a') - anchor.href = url - if (filename) anchor.download = filename + anchor.href = objectUrl + anchor.download = filename ?? 'download' anchor.rel = 'noopener' document.body.append(anchor) anchor.click() anchor.remove() + setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000) } export function useBookDownload() { @@ -19,7 +23,7 @@ export function useBookDownload() { async function downloadFile(fileId: number): Promise { isDownloading.value = true try { - triggerBrowserDownload(`/api/v1/books/files/${fileId}/download`) + await triggerBrowserDownload(`/api/v1/books/files/${fileId}/download`) } catch { toast.error('Download failed') } finally { @@ -39,7 +43,7 @@ export function useBookDownload() { scope, }) toast.dismiss(toastId) - triggerBrowserDownload(`/api/v1/books/export/download?${params.toString()}`) + await triggerBrowserDownload(`/api/v1/books/export/download?${params.toString()}`) } catch { toast.dismiss(toastId) toast.error('Export failed') diff --git a/server/src/modules/book-dock/book-dock-finalize.service.test.ts b/server/src/modules/book-dock/book-dock-finalize.service.test.ts index 62adaafd7..3d2600ad7 100644 --- a/server/src/modules/book-dock/book-dock-finalize.service.test.ts +++ b/server/src/modules/book-dock/book-dock-finalize.service.test.ts @@ -10,7 +10,12 @@ vi.mock('fs/promises', () => ({ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { access, lstat, readdir, readFile, stat, unlink } from 'fs/promises'; -import { NotificationType, type BookDockMetadata } from '@bookorbit/types'; +import { + DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE, + DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER, + NotificationType, + type BookDockMetadata, +} from '@bookorbit/types'; import { BookDockFinalizeService } from './book-dock-finalize.service'; const mockAccess = vi.mocked(access); @@ -149,6 +154,11 @@ function makeRow(overrides?: Partial>) { }; } +/** A book the shipped default patterns can fill every optional segment of. */ +function defaultPatternMetadata(): BookDockMetadata { + return { title: 'Dune', authors: ['Frank Herbert'], seriesName: 'Dune', seriesIndex: '1', publishedYear: 1965 } as BookDockMetadata; +} + describe('BookDockFinalizeService', () => { beforeEach(() => { vi.clearAllMocks(); @@ -636,6 +646,34 @@ describe('BookDockFinalizeService', () => { expect(processor.createBookRecord).toHaveBeenCalledWith(5, 9, '/library/new', '/library/new/book.epub', 'new/book.epub', 'epub', 100); }); + it('files a book_per_file book under the pattern folders instead of the library root', async () => { + const { service, appSettings, processor, storage } = makeService(); + appSettings.getUploadPattern.mockResolvedValue(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE); + 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, 'applyMetadata').mockResolvedValue(undefined as never); + vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never); + mockStat.mockResolvedValueOnce({ size: 100 } as never); + processor.createBookRecord.mockResolvedValueOnce({ bookId: 557 }); + const row = makeRow({ targetLibraryId: 5, targetFolderId: 9, selectedMetadata: defaultPatternMetadata() }); + const destPath = '/library/Frank Herbert/Dune/01. Dune (1965).epub'; + + await expect((service as any).finalizeFile(row, undefined, undefined, new Map(), 1, true)).resolves.toEqual({ + fileId: 1, + fileName: 'book.epub', + newName: 'Frank Herbert/Dune/01. Dune (1965).epub', + success: true, + bookId: 557, + }); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/book.epub', destPath); + expect(processor.createBookRecord).toHaveBeenCalledWith(5, 9, destPath, destPath, 'Frank Herbert/Dune/01. Dune (1965).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({ @@ -1088,6 +1126,29 @@ describe('BookDockFinalizeService', () => { expect(result[0].newName).toBe('CON_/AUX_.epub'); }); + it.each([ + ['book_per_file', DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE, 'Frank Herbert/Dune/01. Dune (1965).epub'], + ['book_per_folder', DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER, 'Frank Herbert/Dune/01. Dune (1965)/01. Dune (1965).epub'], + ])('previewNames promises the destination finalize actually uses for a %s library', async (organizationMode, pattern, expected) => { + const { service, repo, appSettings, db } = makeService(); + const library = { id: 10, name: 'Books', fileNamingPattern: null, organizationMode }; + const row = makeRow({ id: 1, targetLibraryId: 10, selectedMetadata: defaultPatternMetadata() }); + repo.findByIds.mockResolvedValue([row]); + appSettings.getUploadPattern.mockResolvedValue(organizationMode === 'book_per_file' ? pattern : null); + appSettings.getUploadPatternBookPerFolder.mockResolvedValue(organizationMode === 'book_per_folder' ? pattern : null); + db.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([library]), + }), + }); + + const [preview] = await service.previewNames([1], false, [], undefined, 1, true); + const destPath = await (service as any).resolveDestination(library, '/library', row, 'epub'); + + expect(preview.newName).toBe(expected); + expect(destPath).toBe(`/library/${preview.newName}`); + }); + it('applyMetadata updates scalar metadata fields and related author/genre rows', async () => { const { service, db, metadataService, metadataScoreService } = makeService(); const updateChain = { @@ -1575,9 +1636,10 @@ describe('BookDockFinalizeService', () => { expect(storage.moveToPath).not.toHaveBeenCalled(); }); - it('resolveDestination builds names from patterns and falls back to original filename', async () => { + it('resolveDestination builds names from patterns and falls back per organization mode', async () => { const { service, appSettings } = makeService(); appSettings.getUploadPattern.mockResolvedValue(null); + appSettings.getUploadPatternBookPerFolder.mockResolvedValue(null); const rowWithMeta = makeRow({ fileName: 'original.epub', selectedMetadata: { title: 'Dune', seriesIndex: '2.5' } as BookDockMetadata, @@ -1586,9 +1648,12 @@ describe('BookDockFinalizeService', () => { await expect((service as any).resolveDestination({ fileNamingPattern: '{title}-{seriesIndex}' }, '/library', rowWithMeta, 'epub')).resolves.toBe( '/library/Dune-02.5.epub', ); - await expect((service as any).resolveDestination({ fileNamingPattern: null }, '/library', rowWithMeta, 'epub')).resolves.toBe( - '/library/original/original.epub', - ); + await expect( + (service as any).resolveDestination({ fileNamingPattern: null, organizationMode: 'book_per_folder' }, '/library', rowWithMeta, 'epub'), + ).resolves.toBe('/library/original/original.epub'); + await expect( + (service as any).resolveDestination({ fileNamingPattern: null, organizationMode: 'book_per_file' }, '/library', rowWithMeta, 'epub'), + ).resolves.toBe('/library/original.epub'); }); it('resolveDestination uses folder-mode global pattern for book_per_folder libraries', async () => { @@ -1615,6 +1680,29 @@ describe('BookDockFinalizeService', () => { expect(appSettings.getUploadPatternBookPerFolder).not.toHaveBeenCalled(); }); + it('resolveDestination keeps the folder segments a book_per_file pattern defines', async () => { + const { service, appSettings } = makeService(); + appSettings.getUploadPattern.mockResolvedValue('{authors:first}/{series}/{title}'); + const row = makeRow({ + fileName: 'book.epub', + selectedMetadata: { title: 'Foundation', authors: ['Isaac Asimov'], seriesName: 'Foundation' } as BookDockMetadata, + }); + + await expect( + (service as any).resolveDestination({ fileNamingPattern: null, organizationMode: 'book_per_file' }, '/library', row, 'epub'), + ).resolves.toBe('/library/Isaac Asimov/Foundation/Foundation.epub'); + }); + + it('resolveDestination files a book_per_file library under the shipped default pattern', async () => { + const { service, appSettings } = makeService(); + appSettings.getUploadPattern.mockResolvedValue(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE); + const row = makeRow({ fileName: 'book.epub', selectedMetadata: defaultPatternMetadata() }); + + await expect( + (service as any).resolveDestination({ fileNamingPattern: null, organizationMode: 'book_per_file' }, '/library', row, 'epub'), + ).resolves.toBe('/library/Frank Herbert/Dune/01. Dune (1965).epub'); + }); + it('resolveDestination library pattern wins over mode-specific global pattern', async () => { const { service, appSettings } = makeService(); appSettings.getUploadPatternBookPerFolder.mockResolvedValue('{authors:first}/{title}/'); diff --git a/server/src/modules/book-dock/book-dock-finalize.service.ts b/server/src/modules/book-dock/book-dock-finalize.service.ts index e9b16614b..b5408176f 100644 --- a/server/src/modules/book-dock/book-dock-finalize.service.ts +++ b/server/src/modules/book-dock/book-dock-finalize.service.ts @@ -28,7 +28,7 @@ import type { ComicMetadataFields, MetadataSeriesMembership, } from '@bookorbit/types'; -import { MetadataProviderKey, NotificationType, Permission, parseSeriesIndex, resolveDownloadFilename, resolveUploadPath } from '@bookorbit/types'; +import { MetadataProviderKey, NotificationType, Permission, parseSeriesIndex, 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'; @@ -58,6 +58,7 @@ import type { BookDockFileRow } from '../../db/schema'; type Db = NodePgDatabase; type LibraryRow = typeof libraries.$inferSelect; type LibraryFolderRow = typeof libraryFolders.$inferSelect; +type NamingLibrary = { name?: string | null; fileNamingPattern?: string | null; organizationMode?: string | null }; type FinalizeOverrideEntry = { libraryId?: number; @@ -735,54 +736,53 @@ 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 ?? {}; 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.buildFilePatternTokens(meta, row.fileName, format, lib?.name); - const resolved = - lib?.organizationMode === 'book_per_file' - ? resolveDownloadFilename(pattern, tokens, format, { sanitizeForCrossPlatform }) - : resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform }); - if (resolved) newName = resolved; - } + const pattern = lib?.fileNamingPattern ?? (lib?.organizationMode === 'book_per_folder' ? appPatternFolder : appPatternFile); + const newName = this.resolveRelativeDestination(lib, row, format, pattern, sanitizeForCrossPlatform); return { fileId: row.id, fileName: row.fileName, newName }; }); } - private async resolveDestination( - library: { name?: string | null; fileNamingPattern?: string | null; organizationMode?: string | null }, - folderPath: string, - row: BookDockFileRow, - format: string, - ): Promise { + private async resolveDestination(library: NamingLibrary, folderPath: string, row: BookDockFileRow, format: string): Promise { const pattern = library.fileNamingPattern ?? (library.organizationMode === 'book_per_folder' ? await this.appSettings.getUploadPatternBookPerFolder() : await this.appSettings.getUploadPattern()); const sanitizeForCrossPlatform = await this.appSettings.isCrossPlatformPathSanitizationEnabled(); - const meta = row.selectedMetadata ?? row.embeddedMetadata ?? {}; + return join(folderPath, this.resolveRelativeDestination(library, row, format, pattern, sanitizeForCrossPlatform)); + } + + /** + * Where a docked file belongs under its library folder. The finalize move and the name + * preview both go through here so they cannot disagree about the destination. + * + * Both organization modes keep the pattern's folder segments, matching the rename and move + * services; `book_per_file` differs only in that the file itself is the book, so without a + * pattern it stays where it is instead of gaining a folder of its own. + */ + private resolveRelativeDestination( + library: NamingLibrary | undefined, + row: BookDockFileRow, + format: string, + pattern: string | null, + sanitizeForCrossPlatform: boolean, + ): string { if (pattern) { - const tokens = this.buildFilePatternTokens(meta, row.fileName, format, library.name); - const resolved = - library.organizationMode === 'book_per_file' - ? resolveDownloadFilename(pattern, tokens, format, { sanitizeForCrossPlatform }) - : resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform }); - if (resolved) return join(folderPath, resolved); + const meta = row.selectedMetadata ?? row.embeddedMetadata ?? {}; + const tokens = this.buildFilePatternTokens(meta, row.fileName, format, library?.name); + const resolved = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform }); + if (resolved) return resolved; } - if (library.organizationMode === 'book_per_file') return join(folderPath, row.fileName); + if (library?.organizationMode === 'book_per_folder') { + return join(basename(row.fileName, extname(row.fileName)), row.fileName); + } - const stem = basename(row.fileName, extname(row.fileName)); - return join(folderPath, stem, row.fileName); + return row.fileName; } private buildFilePatternTokens(meta: BookDockMetadata, fileName: string, format: string, libraryName?: string | null): Record { diff --git a/server/src/modules/upload/upload.service.test.ts b/server/src/modules/upload/upload.service.test.ts index 41707d34f..04a7d45ce 100644 --- a/server/src/modules/upload/upload.service.test.ts +++ b/server/src/modules/upload/upload.service.test.ts @@ -12,6 +12,7 @@ vi.mock('../metadata/lib/pdf-parser', () => ({ parsePdfFile: vi.fn() })); import { access as fsAccess, lstat, readdir, stat } from 'fs/promises'; import { BadRequestException, ConflictException, ForbiddenException, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common'; +import { DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE } from '@bookorbit/types'; import { extractEpubMetadata } from '../metadata/lib/epub'; import { extractCbzMetadata } from '../metadata/lib/cbz-metadata'; import { parseMobiFile } from '../metadata/lib/mobi-parser'; @@ -384,7 +385,7 @@ describe('UploadService', () => { expect(storage.streamToTemp).not.toHaveBeenCalled(); }); - it('book_per_file with pattern uses resolveDownloadFilename for flat layout', async () => { + it('book_per_file with a folderless pattern keeps the file at the library root', async () => { db.select .mockReturnValueOnce( selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}.{extension}', organizationMode: 'book_per_file' }]), @@ -413,6 +414,76 @@ describe('UploadService', () => { expect(processor.createBookRecord).toHaveBeenCalledWith(1, 2, '/library/Dune.epub', '/library/Dune.epub', 'Dune.epub', 'epub', 456); }); + it('book_per_file keeps the folder segments its pattern defines', async () => { + db.select + .mockReturnValueOnce( + selectChain([ + { + id: 1, + allowedFormats: ['epub'], + fileNamingPattern: '{authors:first}/{series}/{title}.{extension}', + organizationMode: 'book_per_file', + }, + ]), + ) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + + mockExtractEpubMetadata.mockResolvedValue({ + title: 'Dune', + subtitle: null, + publisher: null, + publishedYear: null, + language: null, + seriesName: 'Dune', + seriesIndex: null, + isbn13: null, + authors: [{ name: 'Frank Herbert' }], + tags: [], + description: null, + isbn10: null, + }); + + const result = await service.upload(1, 2, 'raw.epub', {} as any, user); + + expect(result).toEqual({ bookId: 99, filename: 'Dune.epub', format: 'epub', sizeBytes: 456 }); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Frank Herbert/Dune/Dune.epub'); + expect(processor.createBookRecord).toHaveBeenCalledWith( + 1, + 2, + '/library/Frank Herbert/Dune/Dune.epub', + '/library/Frank Herbert/Dune/Dune.epub', + 'Frank Herbert/Dune/Dune.epub', + 'epub', + 456, + ); + }); + + it('book_per_file files under the shipped default pattern rather than at the library root', async () => { + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_file' }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + appSettings.getUploadPattern.mockResolvedValue(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE); + + mockExtractEpubMetadata.mockResolvedValue({ + title: 'Dune', + subtitle: null, + publisher: null, + publishedYear: 1965, + language: null, + seriesName: 'Dune', + seriesIndex: '1', + isbn13: null, + authors: [{ name: 'Frank Herbert' }], + tags: [], + description: null, + isbn10: null, + }); + + await service.upload(1, 2, 'raw.epub', {} as any, user); + + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Frank Herbert/Dune/01. Dune (1965).epub'); + }); + it('book_per_file without pattern uses filename directly', async () => { db.select .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_file' }])) diff --git a/server/src/modules/upload/upload.service.ts b/server/src/modules/upload/upload.service.ts index ad1f9d86f..b66e9a867 100644 --- a/server/src/modules/upload/upload.service.ts +++ b/server/src/modules/upload/upload.service.ts @@ -18,7 +18,7 @@ import { UploadValidatorService } from './upload-validator.service'; import { UploadStorageService } from './upload-storage.service'; import { UploadProcessorService } from './upload-processor.service'; import { FileRenameService } from '../file-write/file-rename.service'; -import { isAudioFormat, resolveDownloadFilename, resolveUploadPath } from '@bookorbit/types'; +import { isAudioFormat, resolveUploadPath } from '@bookorbit/types'; import type { AddBookFileResult, UploadResult } from '@bookorbit/types'; import { extractEpubMetadata } from '../metadata/lib/epub'; import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata'; @@ -331,59 +331,45 @@ export class UploadService { this.logger.log(`[${event}] [end] bookId=${bookId} userId=${user.id} durationMs=${Date.now() - startedAt} - rename book files completed`); } + /** + * Both organization modes keep the pattern's folder segments, so an upload lands where the + * rename and move services would put the same book. The modes differ only in what counts as + * the book: in `book_per_file` the file itself is the book key, not the folder holding it. + */ private async resolveDestination( library: { name?: string | null; fileNamingPattern?: string | null; organizationMode?: string | null }, libraryFolderPath: string, tempPath: string, filename: string, format: string, - ): Promise<{ absolutePath: string; bookFolderPath: string; relPath: string }> { + ): Promise<{ absolutePath: string; bookFolderPath: string }> { const pattern = library.fileNamingPattern ?? (library.organizationMode === 'book_per_folder' ? await this.appSettings.getUploadPatternBookPerFolder() : await this.appSettings.getUploadPattern()); const sanitizeForCrossPlatform = await this.appSettings.isCrossPlatformPathSanitizationEnabled(); + const isBookPerFile = library.organizationMode === 'book_per_file'; if (pattern) { const stem = basename(filename, extname(filename)); const tokens = await this.buildUploadPatternTokens(tempPath, format, stem, library.name); - if (library.organizationMode === 'book_per_file') { - const resolvedFilename = resolveDownloadFilename(pattern, tokens, format, { sanitizeForCrossPlatform }); - if (resolvedFilename) { - const absolutePath = join(libraryFolderPath, resolvedFilename); - return { - absolutePath, - bookFolderPath: absolutePath, - relPath: resolvedFilename, - }; - } - } else { - const resolved = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform }); + const resolved = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform }); - if (resolved) { - const absolutePath = join(libraryFolderPath, resolved); - const relPath = resolved; - const bookFolderPath = dirname(absolutePath); - return { absolutePath, bookFolderPath, relPath }; - } + if (resolved) { + const absolutePath = join(libraryFolderPath, resolved); + return { absolutePath, bookFolderPath: isBookPerFile ? absolutePath : dirname(absolutePath) }; } } - if (library.organizationMode === 'book_per_file') { + if (isBookPerFile) { const absolutePath = join(libraryFolderPath, filename); - return { - absolutePath, - bookFolderPath: absolutePath, - relPath: filename, - }; + return { absolutePath, bookFolderPath: absolutePath }; } const stem = basename(filename, extname(filename)); const bookFolderPath = join(libraryFolderPath, stem); - const absolutePath = join(bookFolderPath, filename); - const relPath = join(stem, filename); - return { absolutePath, bookFolderPath, relPath }; + return { absolutePath: join(bookFolderPath, filename), bookFolderPath }; } private async buildUploadPatternTokens( diff --git a/server/test/book-dock-ingest-finalize.e2e-spec.ts b/server/test/book-dock-ingest-finalize.e2e-spec.ts index e53781c74..035ce137f 100644 --- a/server/test/book-dock-ingest-finalize.e2e-spec.ts +++ b/server/test/book-dock-ingest-finalize.e2e-spec.ts @@ -663,6 +663,65 @@ describe('Book Dock ingest + finalize (e2e)', () => { expect(book?.folderPath).toBe(dirname(bookFile!.absolutePath)); }); + it('finalize files a book_per_file library under the shipped default pattern folders', async () => { + const destination = await createLibraryWithFolder(context, { mode: 'book_per_file' }); + + const bookDockRow = await createBookDockRow(context, { + fileName: 'per-file-default-pattern.fb2', + selectedMetadata: { + title: 'Caliban Cove', + authors: ['S.D. Perry'], + seriesName: 'Resident Evil', + seriesIndex: '2', + publishedYear: 2012, + }, + targetLibraryId: destination.libraryId, + targetFolderId: destination.libraryFolderId, + }); + + const previewResponse = await context.app.inject({ + method: 'POST', + url: '/api/v1/book-dock/files/preview-names', + headers: authHeader(context.adminToken), + payload: { fileIds: [bookDockRow.id] }, + }); + + expect(previewResponse.statusCode).toBe(201); + const preview = previewResponse.json() as Array<{ fileId: number; newName: string }>; + expect(preview[0]?.newName).toBe('S.D. Perry/Resident Evil/02. Caliban Cove (2012).fb2'); + + const response = await context.app.inject({ + method: 'POST', + url: '/api/v1/book-dock/finalize', + headers: authHeader(context.adminToken), + payload: { fileIds: [bookDockRow.id] }, + }); + + expect(response.statusCode).toBe(201); + const body = response.json() as BookDockFinalizeResult; + expect(body).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); + expect(body.results[0]?.newName).toBe(preview[0]?.newName); + + const finalizedBookId = body.results[0]!.bookId!; + const [book] = await context.db + .select({ folderPath: schema.books.folderPath }) + .from(schema.books) + .where(eq(schema.books.id, finalizedBookId)) + .limit(1); + const [bookFile] = await context.db + .select({ absolutePath: schema.bookFiles.absolutePath, relPath: schema.bookFiles.relPath }) + .from(schema.bookFiles) + .where(eq(schema.bookFiles.bookId, finalizedBookId)) + .limit(1); + + const expectedPath = join(destination.folderPath, 'S.D. Perry', 'Resident Evil', '02. Caliban Cove (2012).fb2'); + expect(bookFile?.absolutePath).toBe(expectedPath); + expect(bookFile?.relPath).toBe('S.D. Perry/Resident Evil/02. Caliban Cove (2012).fb2'); + expect(await fileExists(expectedPath)).toBe(true); + expect(await fileExists(join(destination.folderPath, '02. Caliban Cove (2012).fb2'))).toBe(false); + expect(book?.folderPath).toBe(expectedPath); + }); + it('finalize treats title duplicates as exact matches instead of wildcard patterns', async () => { const destination = await createLibraryWithFolder(context);