From 399129e447e6a3bcc2e88ae1869e638a763a8454 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:55:32 +0000 Subject: [PATCH] fix(scanner): keep pattern folders when filing into File as Book libraries Imports resolved the naming pattern with the download resolver, which keeps only the last path segment, so the shipped default's author and series folders were dropped and every file landed at the library root. Rename, bulk rename and move all kept them, leaving the app disagreeing with itself about where its own import belonged. Closes #1157 --- .../book/composables/useBookDownload.ts | 14 ++- .../book-dock-finalize.service.test.ts | 98 ++++++++++++++++++- .../book-dock/book-dock-finalize.service.ts | 62 ++++++------ .../src/modules/upload/upload.service.test.ts | 73 +++++++++++++- server/src/modules/upload/upload.service.ts | 44 +++------ .../book-dock-ingest-finalize.e2e-spec.ts | 59 +++++++++++ 6 files changed, 279 insertions(+), 71 deletions(-) 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);