fix(scanner): preserve separate books at the library root

Use file identities for root-level imports and renames to match scanner
grouping and prevent unrelated books from sharing metadata.
Cover repeated scans, metadata write-back, and stable reading and file
identities with regression tests.

Fixes #1599
This commit is contained in:
neon
2026-10-07 20:51:27 +00:00
parent 5c093333c5
commit 0d426db88e
9 changed files with 266 additions and 46 deletions
@@ -0,0 +1,27 @@
import { resolveSingleFileBookPath } from './book-path.utils';
describe('resolveSingleFileBookPath', () => {
it.each(['book_per_folder', 'book_per_file', null, undefined])('separates root files with organizationMode=%s', (mode) => {
expect(resolveSingleFileBookPath('/library/first.epub', '/library', mode)).toBe('/library/first.epub');
expect(resolveSingleFileBookPath('/library/second.epub', '/library', mode)).toBe('/library/second.epub');
});
it.each(['/library/', '/library/./', '/library/unused/..'])('recognizes equivalent library roots: %s', (root) => {
expect(resolveSingleFileBookPath('/library/book.epub', root, 'book_per_folder')).toBe('/library/book.epub');
});
it.each(['book_per_folder', null, undefined])('groups formats inside a book folder with organizationMode=%s', (mode) => {
expect(resolveSingleFileBookPath('/library/Author/Book/book.epub', '/library', mode)).toBe('/library/Author/Book');
expect(resolveSingleFileBookPath('/library/Author/Book/book.pdf', '/library', mode)).toBe('/library/Author/Book');
});
it('keeps nested files separate in File as Book mode', () => {
expect(resolveSingleFileBookPath('/library/Author/book.epub', '/library', 'book_per_file')).toBe('/library/Author/book.epub');
expect(resolveSingleFileBookPath('/library/Author/book.pdf', '/library', 'book_per_file')).toBe('/library/Author/book.pdf');
});
it('does not mistake a nested or similarly prefixed directory for the library root', () => {
expect(resolveSingleFileBookPath('/library/Author/book.epub', '/library', 'book_per_folder')).toBe('/library/Author');
expect(resolveSingleFileBookPath('/library-other/Book/book.epub', '/library', 'book_per_folder')).toBe('/library-other/Book');
});
});
@@ -0,0 +1,7 @@
import { dirname, resolve } from 'path';
export function resolveSingleFileBookPath(absolutePath: string, libraryFolderPath: string, organizationMode: string | null | undefined): string {
const parent = dirname(absolutePath);
// The scanner treats root-level content files as separate books even in Folder as Book mode.
return organizationMode === 'book_per_file' || resolve(parent) === resolve(libraryFolderPath) ? absolutePath : parent;
}
@@ -763,6 +763,71 @@ describe('BookDockFinalizeService', () => {
]);
});
it.each(['book_per_folder', 'book_per_file'])('keeps unrelated root imports separate in %s mode', async (organizationMode) => {
const { service, processor, storage } = makeService();
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({
id: 5,
allowedFormats: ['epub'],
fileNamingPattern: '<{title}|{originalFilename}> - <{authors:first}> - <{year}>',
organizationMode,
} as never);
vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never);
const applyMetadata = vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never);
vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never);
mockAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockStat.mockResolvedValue({ size: 100 } as never);
for (const [index, title] of ['First Book', 'Second Book'].entries()) {
processor.createUnitBookRecords.mockResolvedValueOnce({ bookIds: [101 + index], createdBookIds: [101 + index], attachedFileIds: [] });
const row = makeRow({
id: index + 1,
targetLibraryId: 5,
targetFolderId: 9,
selectedMetadata: { title, authors: ['Test Author'], publishedYear: 2024 },
});
const result = await (service as any).finalizeFile(row, undefined, undefined, new Map(), 1, true);
expect(result).toMatchObject({ success: true, bookId: 101 + index, newName: `${title} - Test Author - 2024.epub` });
const destPath = `/library/${title} - Test Author - 2024.epub`;
expect(storage.moveToPath).toHaveBeenNthCalledWith(index + 1, row.absolutePath, destPath);
expect(processor.createUnitBookRecords).toHaveBeenNthCalledWith(index + 1, 5, 9, [
expect.objectContaining({ folderPath: destPath, absolutePath: destPath }),
]);
expect(applyMetadata).toHaveBeenNthCalledWith(index + 1, 101 + index, row, true);
}
});
it('uses the persisted filename spelling for a root book identity', async () => {
const { service, processor } = makeService();
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({
id: 5,
allowedFormats: ['epub'],
fileNamingPattern: '{title}',
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, 'applyMetadata').mockResolvedValue(undefined as never);
vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never);
mockAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockStat.mockResolvedValue({ size: 100 } as never);
mockLstat.mockResolvedValue({} as never);
mockReaddir.mockResolvedValue(['dune.epub'] as never);
const result = await (service as any).finalizeFile(
makeRow({ targetLibraryId: 5, targetFolderId: 9, selectedMetadata: { title: 'Dune' } }),
undefined,
undefined,
new Map(),
1,
true,
);
expect(result).toMatchObject({ success: true, newName: 'dune.epub' });
expect(processor.createUnitBookRecords).toHaveBeenCalledWith(5, 9, [
expect.objectContaining({ folderPath: '/library/dune.epub', absolutePath: '/library/dune.epub' }),
]);
});
describe('organization modes', () => {
it('attaches pdf to existing book folder in book_per_folder mode', async () => {
const { service, processor } = makeService();
@@ -47,6 +47,7 @@ import { SeriesIdentityService } from '../../common/services/series-identity.ser
import { SeriesMembershipService } from '../../common/services/series-membership.service';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { resolveExistingPathSpelling } from '../../common/utils/path-identity.utils';
import { resolveSingleFileBookPath } from '../../common/utils/book-path.utils';
import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils';
import { buildPatternTokens, patternReferencesToken } from '../../common/utils/pattern-tokens.utils';
import { DB } from '../../db';
@@ -665,7 +666,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
if (unitFiles.length === 0) {
return {
files: [{ sourcePath: row.absolutePath, destPath, format: row.format, role: 'content', sortOrder: 0 }],
bookFolderPath: library.organizationMode === 'book_per_file' ? destPath : dirname(destPath),
bookFolderPath: resolveSingleFileBookPath(destPath, folderPath, library.organizationMode),
};
}
@@ -750,7 +751,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
if (placement.length === 0) throw new Error('Finalization placed no files');
const loose = library.organizationMode === 'book_per_file';
const sharedFolderPath = bookFolderPath ?? dirname(persistedPrimaryPath);
const sharedFolderPath = bookFolderPath === placement[0]!.destPath ? persistedPrimaryPath : (bookFolderPath ?? dirname(persistedPrimaryPath));
const files: UnitBookFileInput[] = [];
for (const [index, file] of placement.entries()) {
@@ -1160,6 +1160,29 @@ describe('FileRenameService', () => {
expect(mockRename).toHaveBeenCalledWith('/library/Frank Herbert/Dune/Dune.epub', '/library/Frank Herbert/Dune.epub');
});
it.each(['book_per_folder', 'book_per_file'])('preserves a root file identity after renaming in %s mode', async (organizationMode) => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode,
fileNamingPattern: '{title}',
file: { absolutePath: '/library/Old Title.epub', relPath: 'Old Title.epub' },
bookFolderPath: '/library/Old Title.epub',
}),
);
const result = await service.performRename(5, 12);
expect(result).toMatchObject({ status: 'success', newPath: '/library/Dune.epub' });
expect(renameRepo.applyFolderRename).toHaveBeenCalledWith(
5,
[{ id: 10, absolutePath: '/library/Dune.epub', relPath: 'Dune.epub' }],
'/library/Dune.epub',
);
expect(renameRepo.findFolderOwners).not.toHaveBeenCalled();
expect(mockRename).toHaveBeenCalledWith('/library/Old Title.epub', '/library/Dune.epub');
});
it('moves a flat file into a new per-book folder when bookFolderPath equals the file path', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
@@ -8,6 +8,7 @@ import { NotificationType, resolveUploadPath, sanitizePathSegment } from '@booko
import { SelfWriteRegistry } from '../../common/services/self-write-registry.service';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils';
import { resolveSingleFileBookPath } from '../../common/utils/book-path.utils';
import { AppSettingsService } from '../app-settings/app-settings.service';
import type { BookCoverSlotRow } from '../book-cover-store/book-cover-store.repository';
import { BookCoverStore } from '../book-cover-store/book-cover-store.service';
@@ -156,6 +157,7 @@ export class FileRenameService implements OnModuleDestroy {
const newAbsolutePath = fileTargets.get(data.file.id) ?? baseNewAbsolutePath;
const newFolderPath = dirname(newAbsolutePath);
const newBookPath = isBookPerFolder ? resolveSingleFileBookPath(newAbsolutePath, data.libraryFolderPath, data.organizationMode) : newAbsolutePath;
let pathUnchanged = newAbsolutePath === currentAbsolutePath;
if (pathUnchanged) {
@@ -174,7 +176,7 @@ export class FileRenameService implements OnModuleDestroy {
}
let mergeTargetBookId: number | null = null;
if (isBookPerFolder && newFolderPath !== currentFolderPath) {
if (isBookPerFolder && newBookPath === newFolderPath && newFolderPath !== currentFolderPath) {
const owner = (await this.renameRepo.findFolderOwners(data.libraryId, [newFolderPath])).get(newFolderPath);
if (owner && owner.bookId !== bookId) {
const sameWork = isSameWork({ title: data.metadata.title, primaryAuthor: data.authors[0] ?? null }, owner);
@@ -263,8 +265,7 @@ export class FileRenameService implements OnModuleDestroy {
await this.renameBookWithFolder(bookId, data, currentFolderPath, newFolderPath, fileTargets, sanitizeForCrossPlatform);
}
} else {
const nextBookFolderPath = isBookPerFolder ? newFolderPath : newAbsolutePath;
await this.renameBookFilesOnly(bookId, data, fileTargets, nextBookFolderPath, sanitizeForCrossPlatform);
await this.renameBookFilesOnly(bookId, data, fileTargets, newBookPath, sanitizeForCrossPlatform);
}
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
@@ -878,6 +878,44 @@ describe('UploadService', () => {
expect(appSettings.getUploadPatternBookPerFolder).not.toHaveBeenCalled();
});
it.each([
{ mode: 'book_per_folder', pattern: '{title}', global: false },
{ mode: 'book_per_folder', pattern: '<{title}|{originalFilename}> - <{authors:first}> - <{year}>', global: false },
{ mode: 'book_per_folder', pattern: '{title}', global: true },
{ mode: 'book_per_file', pattern: '{title}', global: false },
])('keeps sequential root uploads separate with $mode, pattern=$pattern, global=$global', async ({ mode, pattern, global }) => {
for (const title of ['First Book', 'Second Book']) {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: global ? null : pattern, organizationMode: mode }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
appSettings.getUploadPatternBookPerFolder.mockResolvedValue(pattern);
mockExtractEpubMetadata.mockResolvedValue({ title, authors: [{ name: 'Test Author' }], publishedYear: 2024 } as never);
await service.upload(1, 2, 'raw.epub', {} as any, user);
}
const paths = pattern.includes('authors')
? ['/library/First Book - Test Author - 2024.epub', '/library/Second Book - Test Author - 2024.epub']
: ['/library/First Book.epub', '/library/Second Book.epub'];
for (const [index, path] of paths.entries()) {
expect(storage.moveToPath).toHaveBeenNthCalledWith(index + 1, '/tmp/upload.bin', path);
expect(processor.createBookRecord).toHaveBeenNthCalledWith(index + 1, 1, 2, path, path, path.slice('/library/'.length), 'epub', 456);
}
});
it('uses the persisted root filename spelling for both file and book identity', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}', organizationMode: 'book_per_folder' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockResolvedValue({ title: 'Dune', authors: [] } as never);
mockLstat.mockResolvedValue({} as never);
mockReaddir.mockResolvedValue(['dune.epub'] as never);
await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(processor.createBookRecord).toHaveBeenCalledWith(1, 2, '/library/dune.epub', '/library/dune.epub', 'dune.epub', 'epub', 456);
});
it('book_per_folder global pattern used when library pattern is null', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_folder' }]))
+2 -1
View File
@@ -29,6 +29,7 @@ import { parsePdfFile, type PdfParseWarning } from '../metadata/lib/pdf-parser';
import { extractAudioMetadata } from '../metadata/extractors/audio.extractor';
import { computeFileHash } from '../scanner/lib/hash';
import { resolveExistingPathSpelling } from '../../common/utils/path-identity.utils';
import { resolveSingleFileBookPath } from '../../common/utils/book-path.utils';
import { inspectEpubMediaOverlayFields, mediaOverlayCapabilityFromFields } from '../reader/epub/epub-media-overlay-capability';
import { PathPolicyService } from '../path/path-policy.service';
import { FORMATS_WITH_UNBOUNDED_METADATA_READS, MAX_BUFFERED_METADATA_BYTES } from '../../common/constants/upload.constants';
@@ -426,7 +427,7 @@ export class UploadService {
if (resolved) {
const absolutePath = join(libraryFolderPath, resolved);
return { absolutePath, bookFolderPath: isBookPerFile ? absolutePath : dirname(absolutePath) };
return { absolutePath, bookFolderPath: resolveSingleFileBookPath(absolutePath, libraryFolderPath, library.organizationMode) };
}
}
@@ -13,6 +13,7 @@ import {
import * as schema from '../src/db/schema';
import { parseFb2File } from '../src/modules/metadata/lib/fb2-parser';
import { ScannerService } from '../src/modules/scanner/scanner.service';
import { waitForCondition } from './e2e/app-harness';
import { buildFb2Fixture } from './e2e/book-dock/book-dock-fixture-builder';
import { createPdfFixture } from './e2e/metadata-write/metadata-write-fixture-builder';
@@ -809,51 +810,107 @@ describe('Book Dock ingest + finalize (e2e)', () => {
expect(await getBookDockRow(context, candidateRow.id)).toBeUndefined();
});
it('finalize stores a folder path even when the naming pattern is flat', async () => {
const destination = await createLibraryWithFolder(context);
await context.db.update(schema.libraries).set({ fileNamingPattern: '{title}' }).where(eq(schema.libraries.id, destination.libraryId));
it.each([
{ mode: 'book_per_folder' as const, fileWriteEnabled: false },
{ mode: 'book_per_folder' as const, fileWriteEnabled: true },
{ mode: 'book_per_file' as const, fileWriteEnabled: false },
{ mode: 'book_per_file' as const, fileWriteEnabled: true },
])('preserves separate root books and metadata through repeated scans: $mode, writeBack=$fileWriteEnabled', async ({ mode, fileWriteEnabled }) => {
const destination = await createLibraryWithFolder(context, { mode, fileWriteEnabled, fileWriteFb2Enabled: fileWriteEnabled });
const reader = await createUserAndLogin(context);
await context.db
.update(schema.libraries)
.set({ fileNamingPattern: '<{title}|{originalFilename}> - <{authors:first}> - <{year}>' })
.where(eq(schema.libraries.id, destination.libraryId));
const bookDockRow = await createBookDockRow(context, {
fileName: 'flat-pattern.fb2',
selectedMetadata: {
title: 'Flat Pattern Title',
authors: ['Flat Pattern Author'],
},
targetLibraryId: destination.libraryId,
targetFolderId: destination.libraryFolderId,
});
const bookIds: number[] = [];
const paths: string[] = [];
for (const label of ['First', 'Second']) {
const selectedMetadata = { title: `${label} Edited Title`, authors: [`${label} Edited Author`], publishedYear: 2024 };
const row = await createBookDockRow(context, {
fileName: `${label.toLowerCase()}-root.fb2`,
content: buildFb2Fixture({ title: `${label} Embedded Title`, authors: [`${label} Embedded Author`] }),
selectedMetadata,
targetLibraryId: destination.libraryId,
targetFolderId: destination.libraryFolderId,
});
const response = await context.app.inject({
method: 'POST',
url: '/api/v1/book-dock/finalize',
headers: authHeader(context.adminToken),
payload: { fileIds: [row.id] },
});
expect(response.statusCode).toBe(201);
const result = response.json() as BookDockFinalizeResult;
expect(result).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
const bookId = result.results[0]!.bookId!;
expect(bookIds).not.toContain(bookId);
bookIds.push(bookId);
const path = join(destination.folderPath, `${selectedMetadata.title} - ${selectedMetadata.authors[0]} - 2024.fb2`);
paths.push(path);
expect(await fileExists(path)).toBe(true);
expect(await getBookDockRow(context, row.id)).toBeUndefined();
const response = await context.app.inject({
method: 'POST',
url: '/api/v1/book-dock/finalize',
headers: authHeader(context.adminToken),
payload: {
fileIds: [bookDockRow.id],
},
});
if (fileWriteEnabled) {
await waitForCondition(async () => {
expect((await parseFb2File(path))?.title).toBe(selectedMetadata.title);
const [log] = await context.db
.select({ status: schema.fileWriteLog.status })
.from(schema.fileWriteLog)
.where(eq(schema.fileWriteLog.bookId, bookId))
.limit(1);
expect(log?.status).toBe('success');
});
} else {
expect((await parseFb2File(path))?.title).toBe(`${label} Embedded Title`);
}
expect(response.statusCode).toBe(201);
const body = response.json() as BookDockFinalizeResult;
expect(body).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
if (label === 'First') {
await context.db.insert(schema.userBookStatus).values({ userId: reader.userId, bookId, status: 'reading', source: 'manual' });
}
}
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,
})
.from(schema.bookFiles)
.where(eq(schema.bookFiles.bookId, finalizedBookId))
.limit(1);
const readBooks = () =>
context.db
.select({
id: schema.books.id,
status: schema.books.status,
folderPath: schema.books.folderPath,
title: schema.bookMetadata.title,
primaryFileId: schema.books.primaryFileId,
})
.from(schema.books)
.innerJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id))
.where(eq(schema.books.libraryId, destination.libraryId))
.orderBy(schema.books.id);
const beforeScan = await readBooks();
expect(beforeScan).toHaveLength(2);
expect(beforeScan.map((book) => book.id)).toEqual(bookIds);
expect(beforeScan.map((book) => book.title)).toEqual(['First Edited Title', 'Second Edited Title']);
expect(beforeScan.map((book) => book.folderPath)).toEqual(paths);
const readFiles = () =>
context.db
.select({ bookId: schema.bookFiles.bookId, publicId: schema.bookFiles.publicId, path: schema.bookFiles.absolutePath })
.from(schema.bookFiles)
.where(inArray(schema.bookFiles.bookId, bookIds))
.orderBy(schema.bookFiles.bookId);
const beforeFiles = await readFiles();
expect(beforeFiles.map(({ bookId, path }) => ({ bookId, path }))).toEqual(bookIds.map((bookId, index) => ({ bookId, path: paths[index] })));
expect(book?.folderPath).toBe(destination.folderPath);
expect(book?.folderPath).toBe(dirname(bookFile!.absolutePath));
for (let scan = 0; scan < 2; scan++) {
const { jobId } = await context.app.get(ScannerService).startScan(destination.libraryId, 'manual', true);
await waitForCondition(async () => {
const [job] = await context.db.select().from(schema.scanJobs).where(eq(schema.scanJobs.id, jobId)).limit(1);
expect(job).toMatchObject({ status: 'completed', addedCount: 0, missingCount: 0 });
});
expect(await readBooks()).toEqual(beforeScan);
expect(await readFiles()).toEqual(beforeFiles);
const statuses = await context.db
.select({ bookId: schema.userBookStatus.bookId, status: schema.userBookStatus.status, source: schema.userBookStatus.source })
.from(schema.userBookStatus)
.where(inArray(schema.userBookStatus.bookId, bookIds));
expect(statuses).toEqual([{ bookId: bookIds[0], status: 'reading', source: 'manual' }]);
}
});
it('finalize files a book_per_file library under the shipped default pattern folders', async () => {