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
This commit is contained in:
neon
2026-08-27 15:55:32 +00:00
parent c1794b677c
commit 399129e447
6 changed files with 279 additions and 71 deletions
@@ -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<void> {
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<void> {
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')
@@ -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<Record<string, unknown>>) {
};
}
/** 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}/');
@@ -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<typeof schema>;
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<string> {
private async resolveDestination(library: NamingLibrary, folderPath: string, row: BookDockFileRow, format: string): Promise<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 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<string, string> {
@@ -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' }]))
+15 -29
View File
@@ -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(
@@ -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);