From e2aeaf95ea76cf4ace23cff209ec4f92f381e1b6 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:29:32 +0000 Subject: [PATCH] fix(scanner): resolve case-insensitive path identity to prevent duplicate books When library items are scanned or moved on case-insensitive filesystems, paths with non-standard author or folder capitalization point to the same physical disk entries. Treating previous path spellings as distinct caused scanner, upload, dock, and rename services to fail path-reuse lookups and create duplicate book records. Introduce path identity helpers to check whether paths resolve to the same underlying filesystem entry, treating matching entries as path updates rather than creating duplicate books. Closes #1012 --- .../common/utils/path-identity-lookup.test.ts | 146 ++++++++++++++++++ .../common/utils/path-identity.utils.test.ts | 54 +++++++ .../src/common/utils/path-identity.utils.ts | 84 ++++++++++ .../book-dock-finalize.service.test.ts | 10 +- .../book-dock/book-dock-finalize.service.ts | 13 +- .../file-write/file-rename.service.test.ts | 31 +--- .../modules/file-write/file-rename.service.ts | 21 +-- .../file-event-processor.service.test.ts | 10 +- .../scanner/file-event-processor.service.ts | 8 +- .../modules/scanner/scanner.service.test.ts | 100 ++++++++++++ server/src/modules/scanner/scanner.service.ts | 37 +++-- .../src/modules/upload/upload.service.test.ts | 52 ++++++- server/src/modules/upload/upload.service.ts | 20 ++- .../test/scanner-file-operations.e2e-spec.ts | 17 ++ 14 files changed, 528 insertions(+), 75 deletions(-) create mode 100644 server/src/common/utils/path-identity-lookup.test.ts create mode 100644 server/src/common/utils/path-identity.utils.test.ts create mode 100644 server/src/common/utils/path-identity.utils.ts diff --git a/server/src/common/utils/path-identity-lookup.test.ts b/server/src/common/utils/path-identity-lookup.test.ts new file mode 100644 index 000000000..d1aca26b6 --- /dev/null +++ b/server/src/common/utils/path-identity-lookup.test.ts @@ -0,0 +1,146 @@ +vi.mock('fs/promises', () => ({ lstat: vi.fn(), readdir: vi.fn() })); + +import { lstat, readdir } from 'fs/promises'; + +import { pathsReferToSameEntry, resolveExistingPathSpelling } from './path-identity.utils'; + +const mockLstat = vi.mocked(lstat); +const mockReaddir = vi.mocked(readdir); + +function seedDirectories(entriesByPath: Record): void { + mockReaddir.mockImplementation((path) => { + const entries = entriesByPath[String(path)]; + if (!entries) return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })) as never; + return Promise.resolve(entries) as never; + }); +} + +describe('resolveExistingPathSpelling', () => { + beforeEach(() => { + mockLstat.mockReset(); + mockReaddir.mockReset(); + mockLstat.mockResolvedValue({ dev: 1, ino: 42 } as never); + }); + + it('resolves a case-only difference to the spelling stored on disk', async () => { + seedDirectories({ + '/library': ['bell hooks'], + '/library/bell hooks': ['Dune.epub'], + }); + + await expect(resolveExistingPathSpelling('/library/Bell Hooks/Dune.epub', '/library')).resolves.toBe('/library/bell hooks/Dune.epub'); + }); + + it('resolves a Unicode normalization difference to the spelling stored on disk', async () => { + const storedName = 'José Saramago'.normalize('NFD'); + seedDirectories({ + '/library': [storedName], + [`/library/${storedName}`]: ['Blindness.epub'], + }); + + await expect(resolveExistingPathSpelling('/library/José Saramago/Blindness.epub'.normalize('NFC'), '/library')).resolves.toBe( + `/library/${storedName}/Blindness.epub`, + ); + }); + + it('never lists directories above the given base path', async () => { + seedDirectories({ + '/library': ['bell hooks'], + '/library/bell hooks': ['Dune.epub'], + }); + + await resolveExistingPathSpelling('/library/bell hooks/Dune.epub', '/library'); + + expect(mockReaddir).not.toHaveBeenCalledWith('/'); + expect(mockReaddir).not.toHaveBeenCalledWith('/library', expect.anything()); + expect(mockReaddir.mock.calls.map((call) => call[0])).toEqual(['/library', '/library/bell hooks']); + }); + + it('walks up from the filesystem root when no base path is given', async () => { + seedDirectories({ + '/': ['library'], + '/library': ['bell hooks'], + '/library/bell hooks': ['Dune.epub'], + }); + + await expect(resolveExistingPathSpelling('/library/Bell Hooks/Dune.epub')).resolves.toBe('/library/bell hooks/Dune.epub'); + expect(mockReaddir.mock.calls.map((call) => call[0])).toEqual(['/', '/library', '/library/bell hooks']); + }); + + it('prefers an exact match when both spellings exist', async () => { + seedDirectories({ + '/library': ['bell hooks', 'Bell Hooks'], + '/library/Bell Hooks': ['Dune.epub'], + }); + + await expect(resolveExistingPathSpelling('/library/Bell Hooks/Dune.epub', '/library')).resolves.toBe('/library/Bell Hooks/Dune.epub'); + }); + + it('gives up when more than one entry matches case-insensitively', async () => { + seedDirectories({ '/library': ['bell hooks', 'BELL HOOKS'] }); + + await expect(resolveExistingPathSpelling('/library/Bell Hooks/Dune.epub', '/library')).resolves.toBeNull(); + }); + + it('returns null for a path outside the base path', async () => { + seedDirectories({ '/library': ['bell hooks'] }); + + await expect(resolveExistingPathSpelling('/other/bell hooks/Dune.epub', '/library')).resolves.toBeNull(); + expect(mockReaddir).not.toHaveBeenCalled(); + }); + + it('returns null when the path does not exist', async () => { + mockLstat.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }) as never); + seedDirectories({ '/library': ['bell hooks'] }); + + await expect(resolveExistingPathSpelling('/library/bell hooks/Dune.epub', '/library')).resolves.toBeNull(); + }); +}); + +describe('pathsReferToSameEntry', () => { + beforeEach(() => { + mockLstat.mockReset(); + mockReaddir.mockReset(); + mockLstat.mockResolvedValue({ dev: 1, ino: 42 } as never); + seedDirectories({ + '/': ['library'], + '/library': ['bell hooks'], + '/library/bell hooks': ['Dune.epub'], + }); + }); + + it('treats identical paths as the same entry without touching the filesystem', async () => { + await expect(pathsReferToSameEntry('/library/bell hooks/Dune.epub', '/library/bell hooks/Dune.epub')).resolves.toBe(true); + expect(mockLstat).not.toHaveBeenCalled(); + }); + + it('treats a case-only alias of the same inode as the same entry', async () => { + await expect(pathsReferToSameEntry('/library/Bell Hooks/Dune.epub', '/library/bell hooks/Dune.epub')).resolves.toBe(true); + }); + + it('treats a normalization-only alias of the same inode as the same entry', async () => { + const storedName = 'José Saramago'.normalize('NFD'); + seedDirectories({ + '/': ['library'], + '/library': [storedName], + [`/library/${storedName}`]: ['Blindness.epub'], + }); + + await expect( + pathsReferToSameEntry(`/library/${storedName}/Blindness.epub`, '/library/José Saramago/Blindness.epub'.normalize('NFC')), + ).resolves.toBe(true); + }); + + it('treats different inodes behind the same spelling fold as distinct entries', async () => { + mockLstat.mockImplementation((path) => + Promise.resolve(String(path) === '/library/bell hooks/Dune.epub' ? { dev: 1, ino: 42 } : { dev: 1, ino: 43 }), + ); + + await expect(pathsReferToSameEntry('/library/Bell Hooks/Dune.epub', '/library/bell hooks/Dune.epub')).resolves.toBe(false); + }); + + it('does not compare paths that differ by more than case and normalization', async () => { + await expect(pathsReferToSameEntry('/library/bell hooks/Dune.epub', '/library/bell hooks/Other.epub')).resolves.toBe(false); + expect(mockLstat).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/common/utils/path-identity.utils.test.ts b/server/src/common/utils/path-identity.utils.test.ts new file mode 100644 index 000000000..9e27eda7e --- /dev/null +++ b/server/src/common/utils/path-identity.utils.test.ts @@ -0,0 +1,54 @@ +import { link, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { pathsReferToSameEntry, resolveExistingPathSpelling } from './path-identity.utils'; + +describe('path identity utilities', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'bookorbit-path-identity-')); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('returns the directory-entry spelling for an existing path', async () => { + const actualPath = join(root, 'bell hooks', 'Book.epub'); + await mkdir(join(root, 'bell hooks'), { recursive: true }); + await writeFile(actualPath, 'book'); + + const alternatePath = join(root, 'Bell Hooks', 'Book.epub'); + const resolved = await resolveExistingPathSpelling(alternatePath); + + if (resolved === null) { + expect(await resolveExistingPathSpelling(actualPath)).toBe(actualPath); + return; + } + + expect(resolved).toBe(actualPath); + await expect(pathsReferToSameEntry(actualPath, alternatePath)).resolves.toBe(true); + }); + + it('does not conflate distinct case-sensitive hard links', async () => { + const firstPath = join(root, 'Book.epub'); + const secondPath = join(root, 'book.epub'); + await writeFile(firstPath, 'book'); + + try { + await link(firstPath, secondPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + await expect(pathsReferToSameEntry(firstPath, secondPath)).resolves.toBe(true); + return; + } + + await expect(pathsReferToSameEntry(firstPath, secondPath)).resolves.toBe(false); + }); + + it('returns null for a missing path', async () => { + await expect(resolveExistingPathSpelling(join(root, 'missing.epub'))).resolves.toBeNull(); + }); +}); diff --git a/server/src/common/utils/path-identity.utils.ts b/server/src/common/utils/path-identity.utils.ts new file mode 100644 index 000000000..cb6037eb7 --- /dev/null +++ b/server/src/common/utils/path-identity.utils.ts @@ -0,0 +1,84 @@ +import { lstat, readdir } from 'fs/promises'; +import { isAbsolute, join, normalize, parse, relative, sep } from 'path'; + +function foldPathValue(value: string): string { + return value.normalize('NFC').toLowerCase(); +} + +function splitSegments(value: string): string[] { + return value.split(sep).filter(Boolean); +} + +function resolveWalkStart(normalizedPath: string, basePath: string | undefined): { start: string; segments: string[] } | null { + if (basePath === undefined) { + const { root } = parse(normalizedPath); + if (!root) return null; + return { start: root, segments: splitSegments(normalizedPath.slice(root.length)) }; + } + + const normalizedBase = normalize(basePath); + const relativePath = relative(normalizedBase, normalizedPath); + if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) return null; + + return { start: normalizedBase, segments: splitSegments(relativePath) }; +} + +/** + * Resolves the spelling a path is actually stored under, which can differ from the requested + * spelling on case-insensitive or normalization-insensitive filesystems. Passing basePath keeps + * the walk inside a known-good root instead of listing every directory up from the filesystem root. + */ +export async function resolveExistingPathSpelling(inputPath: string, basePath?: string): Promise { + const normalizedPath = normalize(inputPath); + const walk = resolveWalkStart(normalizedPath, basePath); + if (!walk) return null; + + try { + await lstat(normalizedPath); + } catch { + return null; + } + + let currentPath = walk.start; + + for (const segment of walk.segments) { + let entries: string[]; + try { + entries = await readdir(currentPath); + } catch { + return null; + } + + if (entries.includes(segment)) { + currentPath = join(currentPath, segment); + continue; + } + + const foldedSegment = foldPathValue(segment); + const matches = entries.filter((entry) => foldPathValue(entry) === foldedSegment); + if (matches.length !== 1) return null; + currentPath = join(currentPath, matches[0]!); + } + + return currentPath; +} + +export async function pathsReferToSameEntry(firstPath: string, secondPath: string, basePath?: string): Promise { + const normalizedFirst = normalize(firstPath); + const normalizedSecond = normalize(secondPath); + if (normalizedFirst === normalizedSecond) return true; + if (foldPathValue(normalizedFirst) !== foldPathValue(normalizedSecond)) return false; + + try { + const [firstStat, secondStat, resolvedFirst, resolvedSecond] = await Promise.all([ + lstat(normalizedFirst), + lstat(normalizedSecond), + resolveExistingPathSpelling(normalizedFirst, basePath), + resolveExistingPathSpelling(normalizedSecond, basePath), + ]); + + return firstStat.dev === secondStat.dev && firstStat.ino === secondStat.ino && resolvedFirst !== null && resolvedFirst === resolvedSecond; + } catch { + return false; + } +} 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 58b4601c1..60c22b74e 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 @@ -1,17 +1,21 @@ vi.mock('fs/promises', () => ({ access: vi.fn(), + lstat: vi.fn(), + readdir: vi.fn(), readFile: vi.fn(), stat: vi.fn(), unlink: vi.fn(), })); import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { access, readFile, stat, unlink } from 'fs/promises'; +import { access, lstat, readdir, readFile, stat, unlink } from 'fs/promises'; import type { BookDockMetadata } from '@bookorbit/types'; import { BookDockFinalizeService } from './book-dock-finalize.service'; const mockAccess = vi.mocked(access); +const mockLstat = vi.mocked(lstat); +const mockReaddir = vi.mocked(readdir); const mockReadFile = vi.mocked(readFile); const mockStat = vi.mocked(stat); const mockUnlink = vi.mocked(unlink); @@ -148,7 +152,11 @@ describe('BookDockFinalizeService', () => { mockReadFile.mockReset(); mockStat.mockReset(); mockUnlink.mockReset(); + mockLstat.mockReset(); + mockReaddir.mockReset(); mockAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); + mockLstat.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); + mockReaddir.mockResolvedValue([]); mockReadFile.mockResolvedValue(Buffer.from('cover-bytes')); mockStat.mockResolvedValue({ size: 100 } as never); mockUnlink.mockResolvedValue(undefined); 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 34619b97c..f0a59b498 100644 --- a/server/src/modules/book-dock/book-dock-finalize.service.ts +++ b/server/src/modules/book-dock/book-dock-finalize.service.ts @@ -34,6 +34,7 @@ import { NotificationService } from '../notification/notification.service'; import { SeriesIdentityService } from '../../common/services/series-identity.service'; 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 { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils'; import { formatSeriesIndex } from '../../common/utils/series-index-format.utils'; import { DB } from '../../db'; @@ -289,17 +290,18 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots } await this.storage.moveToPath(row.absolutePath, destPath); + const persistedDestPath = (await resolveExistingPathSpelling(destPath, folder.path)) ?? destPath; let bookId: number; try { - const { size } = await stat(destPath); - const bookFolderPath = library.organizationMode === 'book_per_file' ? destPath : dirname(destPath); + const { size } = await stat(persistedDestPath); + const bookFolderPath = library.organizationMode === 'book_per_file' ? persistedDestPath : dirname(persistedDestPath); ({ bookId } = await this.processor.createBookRecord( library.id, folder.id, bookFolderPath, - destPath, - destPath.substring(folder.path.length + 1), + persistedDestPath, + persistedDestPath.substring(folder.path.length + 1), format, size, )); @@ -311,8 +313,9 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots await this.cleanupBookDockRecord(row); existingDestinations.set(this.destinationKey(library.id, destPath), bookId); + existingDestinations.set(this.destinationKey(library.id, persistedDestPath), bookId); - const newName = destPath.substring(folder.path.length + 1); + const newName = persistedDestPath.substring(folder.path.length + 1); return { fileId: row.id, fileName: row.fileName, newName, success: true, bookId }; } catch (err) { const message = resolveFinalizeErrorMessage(err); diff --git a/server/src/modules/file-write/file-rename.service.test.ts b/server/src/modules/file-write/file-rename.service.test.ts index d13ddcf03..194d607c0 100644 --- a/server/src/modules/file-write/file-rename.service.test.ts +++ b/server/src/modules/file-write/file-rename.service.test.ts @@ -1,34 +1,33 @@ import { ConfigService } from '@nestjs/config'; import { NotificationType } from '@bookorbit/types'; import type { MockedFunction } from 'vitest'; -import { access, lstat, mkdir, readdir, realpath, rename as fsRename, rmdir } from 'fs/promises'; +import { access, mkdir, readdir, rename as fsRename, rmdir } from 'fs/promises'; import { SelfWriteRegistry } from '../../common/services/self-write-registry.service'; +import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils'; vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises'); return { ...actual, access: vi.fn(), - lstat: vi.fn(), mkdir: vi.fn(), readdir: vi.fn(), - realpath: vi.fn(), rename: vi.fn(), rmdir: vi.fn(), }; }); +vi.mock('../../common/utils/path-identity.utils', () => ({ pathsReferToSameEntry: vi.fn() })); import type { BookRenameData } from './file-rename.repository'; import { FileRenameService } from './file-rename.service'; import { bookOperationLockKey } from './file-lock.service'; const mockAccess = access as MockedFunction; -const mockLstat = lstat as MockedFunction; const mockMkdir = mkdir as MockedFunction; const mockReaddir = readdir as MockedFunction; -const mockRealpath = realpath as MockedFunction; const mockRename = fsRename as MockedFunction; const mockRmdir = rmdir as MockedFunction; +const mockPathsReferToSameEntry = pathsReferToSameEntry as MockedFunction; type RenameDataOverrides = Partial & { file?: Partial; @@ -127,19 +126,16 @@ describe('FileRenameService', () => { vi.clearAllMocks(); vi.useRealTimers(); mockAccess.mockReset(); - mockLstat.mockReset(); mockMkdir.mockReset(); mockReaddir.mockReset(); - mockRealpath.mockReset(); mockRename.mockReset(); mockRmdir.mockReset(); mockAccess.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }) as never); - mockLstat.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }) as never); mockMkdir.mockResolvedValue(undefined as never); mockReaddir.mockResolvedValue(['still-here'] as never); - mockRealpath.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }) as never); mockRename.mockResolvedValue(undefined as never); mockRmdir.mockResolvedValue(undefined as never); + mockPathsReferToSameEntry.mockImplementation((firstPath, secondPath) => Promise.resolve(firstPath === secondPath)); }); afterEach(() => { @@ -759,8 +755,7 @@ describe('FileRenameService', () => { if (path.toString() === '/library/Frank Herbert/Dune.epub') return Promise.resolve(undefined); return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); }); - mockLstat.mockResolvedValue({ dev: 1, ino: 42 } as never); - mockRealpath.mockResolvedValue('/library/Frank Herbert/Dune.epub'); + mockPathsReferToSameEntry.mockResolvedValue(true); const result = await service.performRename(5, 12); @@ -787,9 +782,6 @@ describe('FileRenameService', () => { }), ); mockAccess.mockResolvedValue(undefined as never); - mockLstat.mockResolvedValue({ dev: 1, ino: 42 } as never); - mockRealpath.mockImplementation((path: any) => Promise.resolve(path.toString())); - const result = await service.performRename(5, 12); expect(result).toEqual(expect.objectContaining({ status: 'skipped', reason: 'target path already exists on disk' })); @@ -813,8 +805,7 @@ describe('FileRenameService', () => { if (path.toString() === '/library/Frank Herbert/Dune') return Promise.resolve(undefined); return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); }); - mockLstat.mockResolvedValue({ dev: 1, ino: 42 } as never); - mockRealpath.mockResolvedValue('/library/Frank Herbert/Dune'); + mockPathsReferToSameEntry.mockResolvedValue(true); const result = await service.performRename(5, 12); @@ -877,14 +868,6 @@ describe('FileRenameService', () => { if (path.toString() === '/library/Bad_ Love.epub') return Promise.resolve(undefined); return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); }); - mockLstat.mockImplementation((path: any) => { - if (path.toString() === '/library/Bad_ Love.epub') return Promise.resolve({ dev: 1, ino: 42 } as never); - return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); - }); - mockRealpath.mockImplementation((path: any) => { - if (path.toString() === '/library/Bad_ Love.epub') return Promise.resolve('/library/Bad_ Love.epub'); - return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); - }); mockRename.mockImplementation((source: any) => { if (source.toString() === '/library/Bad: Love.epub') return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); return Promise.resolve(); diff --git a/server/src/modules/file-write/file-rename.service.ts b/server/src/modules/file-write/file-rename.service.ts index 57f5b82c7..33d1fdae5 100644 --- a/server/src/modules/file-write/file-rename.service.ts +++ b/server/src/modules/file-write/file-rename.service.ts @@ -1,12 +1,13 @@ import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { access, lstat, mkdir, readdir, realpath, rename as fsRename, rmdir } from 'fs/promises'; +import { access, mkdir, readdir, rename as fsRename, rmdir } from 'fs/promises'; import { basename, dirname, extname, isAbsolute, join, normalize, relative, sep } from 'path'; import type { FileRenameResult } from '@bookorbit/types'; import { isAudioFormat, NotificationType, resolveUploadPath, sanitizePathSegment } from '@bookorbit/types'; 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 { AppSettingsService } from '../app-settings/app-settings.service'; import { NotificationService } from '../notification/notification.service'; import type { BookFilePathUpdate, BookRenameData } from './file-rename.repository'; @@ -709,31 +710,17 @@ export class FileRenameService implements OnModuleDestroy { return null; } - private async pathsReferToSameEntry(firstPath: string, secondPath: string): Promise { - try { - const [first, second, resolvedFirst, resolvedSecond] = await Promise.all([ - lstat(firstPath), - lstat(secondPath), - realpath(firstPath), - realpath(secondPath), - ]); - return first.dev === second.dev && first.ino === second.ino && resolvedFirst === resolvedSecond; - } catch { - return false; - } - } - private async pathsReferToSameSource( sourcePath: string, targetPath: string, libraryFolderPath: string, sanitizeForCrossPlatform: boolean, ): Promise { - if (await this.pathsReferToSameEntry(sourcePath, targetPath)) return true; + if (await pathsReferToSameEntry(sourcePath, targetPath)) return true; if (!sanitizeForCrossPlatform) return false; const sanitizedSourcePath = this.buildSanitizedSourcePath(sourcePath, libraryFolderPath); - return sanitizedSourcePath !== null && (await this.pathsReferToSameEntry(sanitizedSourcePath, targetPath)); + return sanitizedSourcePath !== null && (await pathsReferToSameEntry(sanitizedSourcePath, targetPath)); } private async renamePath(sourcePath: string, targetPath: string, libraryFolderPath: string, sanitizeForCrossPlatform: boolean): Promise { diff --git a/server/src/modules/scanner/file-event-processor.service.test.ts b/server/src/modules/scanner/file-event-processor.service.test.ts index b69fbec6c..91502cfe4 100644 --- a/server/src/modules/scanner/file-event-processor.service.test.ts +++ b/server/src/modules/scanner/file-event-processor.service.test.ts @@ -6,14 +6,15 @@ import { Logger } from '@nestjs/common'; vi.mock('fs/promises', () => ({ stat: vi.fn(), readdir: vi.fn(), - realpath: vi.fn(), })); +vi.mock('../../common/utils/path-identity.utils', () => ({ pathsReferToSameEntry: vi.fn() })); -import { stat, readdir, realpath } from 'fs/promises'; +import { stat, readdir } from 'fs/promises'; +import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils'; const mockStat = stat as MockedFunction; const mockReaddir = readdir as MockedFunction; -const mockRealpath = realpath as MockedFunction; +const mockPathsReferToSameEntry = pathsReferToSameEntry as MockedFunction; const mockRepo: Mocked< Pick< @@ -99,6 +100,7 @@ beforeEach(() => { mockRepo.findLibraryFolderPath.mockResolvedValue('/books'); mockRepo.findPresentDuplicateBookFilesByHash.mockResolvedValue([]); mockRepo.replaceDuplicateBookWithMovedBook.mockResolvedValue(null); + mockPathsReferToSameEntry.mockResolvedValue(false); mockReaddir.mockResolvedValue([]); mockRepo.updateBookPrimaryFile.mockResolvedValue(undefined); }); @@ -681,7 +683,7 @@ describe('handleCreate — move detection', () => { const newPath = '/books/Author/book.epub'; const fileStat = makeFileStat({ ino: 4001n, size: 70000 }); mockStat.mockResolvedValue(fileStat); - mockRealpath.mockResolvedValue(newPath); + mockPathsReferToSameEntry.mockResolvedValue(true); mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null); mockRepo.findMissingBookByFolderPath.mockResolvedValue(null); mockRepo.findBookFileWithContextByIno.mockResolvedValue({ diff --git a/server/src/modules/scanner/file-event-processor.service.ts b/server/src/modules/scanner/file-event-processor.service.ts index a8ad5e300..8e11db19b 100644 --- a/server/src/modules/scanner/file-event-processor.service.ts +++ b/server/src/modules/scanner/file-event-processor.service.ts @@ -1,8 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; -import { readdir, realpath, stat } from 'fs/promises'; +import { readdir, stat } from 'fs/promises'; import type { BigIntStats } from 'fs'; import { dirname, join, relative } from 'path'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; +import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils'; import { classifyFile, DEFAULT_FORMAT_PRIORITY } from './lib/classify'; import { ScannerRepository } from './scanner.repository'; @@ -333,9 +334,8 @@ export class FileEventProcessorService { (s) => s.isFile(), () => false, ); - if (oldFileStillExists && file.absolutePath.toLowerCase() === newAbsolutePath.toLowerCase()) { - const canonicalPath = await realpath(file.absolutePath).catch(() => null); - oldFileStillExists = canonicalPath !== newAbsolutePath; + if (oldFileStillExists && (await pathsReferToSameEntry(file.absolutePath, newAbsolutePath))) { + oldFileStillExists = false; } if (oldFileStillExists) { this.logger.log( diff --git a/server/src/modules/scanner/scanner.service.test.ts b/server/src/modules/scanner/scanner.service.test.ts index 01e42d152..05341544d 100644 --- a/server/src/modules/scanner/scanner.service.test.ts +++ b/server/src/modules/scanner/scanner.service.test.ts @@ -1,6 +1,7 @@ vi.mock('./lib/walk'); vi.mock('./lib/hash'); vi.mock('./lib/stability', () => ({ waitForStability: vi.fn().mockResolvedValue(undefined) })); +vi.mock('../../common/utils/path-identity.utils', () => ({ pathsReferToSameEntry: vi.fn() })); vi.mock('fs/promises', async (importOriginal) => { const actual = await importOriginal(); return { @@ -23,12 +24,14 @@ import { DEFAULT_FORMAT_PRIORITY } from './lib/classify'; import type { BookCandidate, FileStat } from './lib/walk'; import { findBookCandidates, findLooseFileCandidates, buildSingleBookCandidate } from './lib/walk'; import { computeFileHash } from './lib/hash'; +import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils'; import * as assembleBookCardsModule from '../book/utils/assemble-book-cards'; const mockFindCandidates = findBookCandidates as MockedFunction; const mockFindLooseCandidates = findLooseFileCandidates as MockedFunction; const mockBuildSingleCandidate = buildSingleBookCandidate as MockedFunction; const mockFingerprint = computeFileHash as MockedFunction; +const mockPathsReferToSameEntry = pathsReferToSameEntry as MockedFunction; const mockReaddir = readdir as MockedFunction; const mockStat = stat as MockedFunction; @@ -190,6 +193,7 @@ beforeEach(() => { mockFindLooseCandidates.mockResolvedValue({ candidates: [], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() }); mockBuildSingleCandidate.mockResolvedValue(null); mockFingerprint.mockResolvedValue('hash-abc'); + mockPathsReferToSameEntry.mockResolvedValue(false); mockReaddir.mockResolvedValue([]); mockStat.mockResolvedValue({ isFile: () => true, ino: 2001n, size: 1024, mtime: new Date('2024-01-01') } as any); delete (mockMetadata as Record).extractAndSaveIfAvailable; @@ -973,6 +977,51 @@ describe('file identity resolution', () => { expect(repo.createBookFile).not.toHaveBeenCalled(); }); + it('reuses the existing book and file for a case-only path change on a case-insensitive filesystem', async () => { + const candidateFile = makeFileStat({ + absolutePath: '/library/bell hooks/Book/book.epub', + relPath: 'bell hooks/Book/book.epub', + ino: 9999n, + }); + const existingFile = makeBookFile({ + id: 21, + bookId: 10, + absolutePath: '/library/Bell Hooks/Book/book.epub', + relPath: 'Bell Hooks/Book/book.epub', + ino: 9999n, + }); + const repo = makeRepo({ + findBooksByLibraryFolder: vi + .fn() + .mockResolvedValue([{ id: 10, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Bell Hooks/Book', status: 'present' }]), + findBookFilesByLibraryFolder: vi.fn().mockResolvedValue([existingFile]), + }); + mockFindCandidates.mockResolvedValue({ + candidates: [makeCandidate('/library/bell hooks/Book', [candidateFile])], + skippedDirs: new Set(), + unchangedDirs: new Set(), + dirMtimes: new Map(), + }); + mockPathsReferToSameEntry.mockResolvedValue(true); + + const done = awaitScan(repo); + const { service } = makeService(repo); + await service.startScan(1, 'manual'); + await done; + + expect(repo.updateBookFolderPath).toHaveBeenCalledWith(10, '/library/bell hooks/Book'); + expect(repo.updateBookFile).toHaveBeenCalledWith( + 21, + expect.objectContaining({ + bookId: 10, + absolutePath: '/library/bell hooks/Book/book.epub', + relPath: 'bell hooks/Book/book.epub', + }), + ); + expect(repo.createBook).not.toHaveBeenCalled(); + expect(repo.createBookFile).not.toHaveBeenCalled(); + }); + it('reuses an existing book id when a MergerFS folder is renamed via an exact oversized inode match', async () => { const renamedFile = makeFileStat({ absolutePath: '/library/Author/NewName/book.epub', @@ -1809,6 +1858,57 @@ describe('cross-library transfer', () => { expect(mockGateway.emitBookTransferred).toHaveBeenCalledWith({ fromLibraryId: 1, toLibraryId: 2, bookIds: [55] }); }); + it('transfers a source book when the previous file path is now a directory', async () => { + const destinationFile = makeFileStat({ + absolutePath: '/dest/Inbox/book.epub', + relPath: 'Inbox/book.epub', + ino: 4343n, + }); + const sourceFile = makeBookFile({ + id: 510, + bookId: 55, + libraryFolderId: 10, + absolutePath: '/source/Book/book.epub', + relPath: 'Book/book.epub', + ino: 4343n, + fileHash: 'transfer-hash', + }); + + const repo = makeRepo({ + findLibraryFolders: vi.fn().mockResolvedValue([{ id: 22, path: '/dest', libraryId: 2 }]), + findBooksByLibraryFolder: vi.fn().mockResolvedValue([]), + findBookFilesByLibraryFolder: vi.fn().mockResolvedValue([]), + findMissingBookFileWithContextByIno: vi.fn().mockResolvedValue(null), + findBookFileWithContextByIno: vi.fn().mockResolvedValue({ + file: sourceFile, + libraryId: 1, + bookStatus: 'present', + folderPath: '/source/Book', + libraryFolderPath: '/source', + }), + findMissingBookFileWithContextByHash: vi.fn().mockResolvedValue(null), + findBookFileWithContextByHash: vi.fn().mockResolvedValue(null), + }); + mockFindCandidates.mockResolvedValue({ + candidates: [makeCandidate('/dest/Inbox', [destinationFile])], + skippedDirs: new Set(), + unchangedDirs: new Set(), + dirMtimes: new Map(), + }); + mockStat.mockImplementation((path) => { + if (String(path) === '/source/Book/book.epub') return Promise.resolve({ isFile: () => false, isDirectory: () => true } as any); + return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + }); + + const done = awaitScan(repo); + const { service } = makeService(repo); + await service.startScan(2, 'manual'); + await done; + + expect(repo.moveBookToLibrary).toHaveBeenCalledWith(55, 2, 22, '/dest/Inbox'); + expect(repo.createBook).not.toHaveBeenCalled(); + }); + it('transfers a missing source book via hash fallback when inode differs', async () => { const destinationFile = makeFileStat({ absolutePath: '/dest/Inbox/book.epub', diff --git a/server/src/modules/scanner/scanner.service.ts b/server/src/modules/scanner/scanner.service.ts index 3d88dad04..aaf5b3997 100644 --- a/server/src/modules/scanner/scanner.service.ts +++ b/server/src/modules/scanner/scanner.service.ts @@ -1,6 +1,7 @@ import { ConflictException, Injectable, Logger, NotFoundException, OnApplicationBootstrap, Optional } from '@nestjs/common'; import { createHash } from 'crypto'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; +import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils'; import type { BookMissingEvent, @@ -1821,7 +1822,8 @@ export class ScannerService implements OnApplicationBootstrap { const sourceBook = this.findBookEntryById(bookByFolderPath, sourceBookId); if (!sourceBook) return null; if (sourceBook.folderPath === candidate.folderPath) return sourceBook; - if (candidateFolderPaths.has(sourceBook.folderPath) || (await this.pathExists(sourceBook.folderPath))) return null; + if (candidateFolderPaths.has(sourceBook.folderPath) || (await this.pathExistsAsDistinctEntry(sourceBook.folderPath, candidate.folderPath))) + return null; await this.scannerRepo.updateBookFolderPath(sourceBook.id, candidate.folderPath); @@ -1877,6 +1879,17 @@ export class ScannerService implements OnApplicationBootstrap { } } + private async pathExistsAsDistinctEntry(existingPath: string, candidatePath: string): Promise { + if (!(await this.pathExists(existingPath))) return false; + return !(await pathsReferToSameEntry(existingPath, candidatePath)); + } + + private async fileExistsAsDistinctEntry(existingPath: string, candidatePath: string): Promise { + const existingStat = await stat(existingPath).catch(() => null); + if (!existingStat?.isFile()) return false; + return !(await pathsReferToSameEntry(existingPath, candidatePath)); + } + private async filterBookIdsMissingOnDisk(bookIds: number[]): Promise { if (bookIds.length === 0) return []; @@ -1915,7 +1928,7 @@ export class ScannerService implements OnApplicationBootstrap { for (const file of contentFiles) { if (file.ino === 0n) continue; const byIno = await this.scannerRepo.findMissingBookFileWithContextByIno(file.ino); - if (!byIno || (await this.pathExists(byIno.file.absolutePath))) continue; + if (!byIno || (await this.pathExistsAsDistinctEntry(byIno.file.absolutePath, file.absolutePath))) continue; sourceBookId = byIno.file.bookId; sourceLibraryId = byIno.libraryId; break; @@ -1926,8 +1939,7 @@ export class ScannerService implements OnApplicationBootstrap { if (file.ino === 0n) continue; const byIno = await this.scannerRepo.findBookFileWithContextByIno(file.ino); if (!byIno || byIno.file.absolutePath === file.absolutePath) continue; - const previousPathStat = await stat(byIno.file.absolutePath).catch(() => null); - if (previousPathStat?.isFile()) continue; + if (await this.fileExistsAsDistinctEntry(byIno.file.absolutePath, file.absolutePath)) continue; sourceBookId = byIno.file.bookId; sourceLibraryId = byIno.libraryId; break; @@ -1946,7 +1958,11 @@ export class ScannerService implements OnApplicationBootstrap { } const byHash = await this.scannerRepo.findMissingBookFileWithContextByHash(fileHash); - if (byHash && byHash.file.sizeBytes === file.sizeBytes && !(await this.pathExists(byHash.file.absolutePath))) { + if ( + byHash && + byHash.file.sizeBytes === file.sizeBytes && + !(await this.pathExistsAsDistinctEntry(byHash.file.absolutePath, file.absolutePath)) + ) { sourceBookId = byHash.file.bookId; sourceLibraryId = byHash.libraryId; break; @@ -1954,8 +1970,7 @@ export class ScannerService implements OnApplicationBootstrap { const byHashAny = await this.scannerRepo.findBookFileWithContextByHash(fileHash); if (!byHashAny || byHashAny.file.absolutePath === file.absolutePath || byHashAny.file.sizeBytes !== file.sizeBytes) continue; - const previousPathStat = await stat(byHashAny.file.absolutePath).catch(() => null); - if (previousPathStat?.isFile()) continue; + if (await this.fileExistsAsDistinctEntry(byHashAny.file.absolutePath, file.absolutePath)) continue; sourceBookId = byHashAny.file.bookId; sourceLibraryId = byHashAny.libraryId; break; @@ -2109,7 +2124,7 @@ export class ScannerService implements OnApplicationBootstrap { if (!byIno) return null; const oldAbsolutePath = byIno.absolutePath; - if (await this.pathExists(oldAbsolutePath)) return null; + if (await this.pathExistsAsDistinctEntry(oldAbsolutePath, fileStat.absolutePath)) return null; const sizeUnchanged = fileStat.sizeBytes === byIno.sizeBytes; const mtimeUnchanged = fileStat.mtime.getTime() === byIno.mtime?.getTime(); await this.scannerRepo.updateBookFile(byIno.id, { @@ -2168,7 +2183,7 @@ export class ScannerService implements OnApplicationBootstrap { ) { return null; } - if (await this.pathExists(globalByIno.file.absolutePath)) return null; + if (await this.pathExistsAsDistinctEntry(globalByIno.file.absolutePath, fileStat.absolutePath)) return null; const oldAbsolutePath = globalByIno.file.absolutePath; const sizeUnchanged = fileStat.sizeBytes === globalByIno.file.sizeBytes; @@ -2246,7 +2261,7 @@ export class ScannerService implements OnApplicationBootstrap { if (!isFirstScan) { const byHash = await this.scannerRepo.findBookFileByHash(fileHash, libraryFolderId); - if (byHash && byHash.sizeBytes === fileStat.sizeBytes && !(await this.pathExists(byHash.absolutePath))) { + if (byHash && byHash.sizeBytes === fileStat.sizeBytes && !(await this.pathExistsAsDistinctEntry(byHash.absolutePath, fileStat.absolutePath))) { const oldAbsolutePath = byHash.absolutePath; await this.scannerRepo.updateBookFile(byHash.id, { bookId, @@ -2304,7 +2319,7 @@ export class ScannerService implements OnApplicationBootstrap { globalByHash.file.absolutePath !== fileStat.absolutePath && globalByHash.file.sizeBytes === fileStat.sizeBytes && (globalByHash.file.bookId === bookId || globalByHash.bookStatus === 'missing') && - !(await this.pathExists(globalByHash.file.absolutePath)) + !(await this.pathExistsAsDistinctEntry(globalByHash.file.absolutePath, fileStat.absolutePath)) ) { const oldAbsolutePath = globalByHash.file.absolutePath; await this.scannerRepo.updateBookFile(globalByHash.file.id, { diff --git a/server/src/modules/upload/upload.service.test.ts b/server/src/modules/upload/upload.service.test.ts index 7978fc020..f5601cf6e 100644 --- a/server/src/modules/upload/upload.service.test.ts +++ b/server/src/modules/upload/upload.service.test.ts @@ -1,6 +1,6 @@ vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises'); - return { ...actual, access: vi.fn(), stat: vi.fn() }; + return { ...actual, access: vi.fn(), lstat: vi.fn(), readdir: vi.fn(), stat: vi.fn() }; }); vi.mock('../scanner/lib/hash', () => ({ computeFileHash: vi.fn() })); @@ -10,7 +10,7 @@ vi.mock('../metadata/lib/cbz-metadata', () => ({ extractCbzMetadata: vi.fn(), ex vi.mock('../metadata/lib/mobi-parser', () => ({ parseMobiFile: vi.fn() })); vi.mock('../metadata/lib/pdf-parser', () => ({ parsePdfFile: vi.fn() })); -import { access as fsAccess, stat } from 'fs/promises'; +import { access as fsAccess, lstat, readdir, stat } from 'fs/promises'; import { BadRequestException, ConflictException, ForbiddenException, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { extractEpubMetadata } from '../metadata/lib/epub'; import { extractCbzMetadata } from '../metadata/lib/cbz-metadata'; @@ -22,6 +22,8 @@ import { books } from '../../db/schema'; import { UploadService } from './upload.service'; const mockFsAccess = fsAccess as MockedFunction; +const mockLstat = lstat as MockedFunction; +const mockReaddir = readdir as MockedFunction; const mockStat = stat as MockedFunction; const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction; const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction; @@ -133,6 +135,8 @@ describe('UploadService', () => { appSettings.getUploadPatternBookPerFolder.mockResolvedValue(null); appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(false); mockFsAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); + mockLstat.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); + mockReaddir.mockResolvedValue([]); mockExtractEpubMetadata.mockResolvedValue(null); // addFileToBook defaults @@ -201,6 +205,50 @@ describe('UploadService', () => { expect(processor.processNewBookImportAsync).toHaveBeenCalledWith(99, 1, '/library/Frank Herbert/Dune.epub', 'epub'); }); + it('stores the directory-entry spelling when an upload targets a case-insensitive parent path', async () => { + db.select + .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{authors:first}/{title}.{extension}' }])) + .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); + mockExtractEpubMetadata.mockResolvedValue({ + title: 'Dune', + subtitle: null, + publisher: null, + publishedYear: null, + language: null, + seriesName: null, + seriesIndex: null, + isbn13: null, + authors: [{ name: 'Bell Hooks' }], + tags: [], + description: null, + isbn10: null, + }); + mockLstat.mockResolvedValue({} as never); + mockReaddir.mockImplementation((path) => { + const entriesByPath: Record = { + '/': ['library'], + '/library': ['bell hooks'], + '/library/bell hooks': ['Dune.epub'], + }; + return Promise.resolve(entriesByPath[String(path)] ?? []) as never; + }); + + await service.upload(1, 2, 'raw.epub', {} as any, user); + + expect(mockReaddir.mock.calls.map((call) => call[0])).toEqual(['/library', '/library/bell hooks']); + expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Bell Hooks/Dune.epub'); + expect(processor.createBookRecord).toHaveBeenCalledWith( + 1, + 2, + '/library/bell hooks', + '/library/bell hooks/Dune.epub', + 'bell hooks/Dune.epub', + 'epub', + 456, + ); + expect(processor.processNewBookImportAsync).toHaveBeenCalledWith(99, 1, '/library/bell hooks/Dune.epub', 'epub'); + }); + it('only extracts metadata when an upload adds a file to an existing book', async () => { processor.createBookRecord.mockResolvedValue({ bookId: 99, created: false }); db.select diff --git a/server/src/modules/upload/upload.service.ts b/server/src/modules/upload/upload.service.ts index 2bfa8bf7c..845922338 100644 --- a/server/src/modules/upload/upload.service.ts +++ b/server/src/modules/upload/upload.service.ts @@ -25,6 +25,7 @@ import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../m import { parseMobiFile } from '../metadata/lib/mobi-parser'; import { parsePdfFile, type PdfParseWarning } from '../metadata/lib/pdf-parser'; import { computeFileHash } from '../scanner/lib/hash'; +import { resolveExistingPathSpelling } from '../../common/utils/path-identity.utils'; type Db = NodePgDatabase; @@ -73,7 +74,7 @@ export class UploadService { let shouldCleanupDestination = false; try { - const { absolutePath, bookFolderPath, relPath } = await this.resolveDestination(library, folder.path, tempPath, filename, format); + const { absolutePath, bookFolderPath } = await this.resolveDestination(library, folder.path, tempPath, filename, format); destinationPath = absolutePath; if (await this.destinationExists(absolutePath)) { @@ -83,26 +84,31 @@ export class UploadService { shouldCleanupDestination = true; await this.storage.moveToPath(tempPath, absolutePath); + const persistedAbsolutePath = (await resolveExistingPathSpelling(absolutePath, folder.path)) ?? absolutePath; + const persistedBookFolderPath = bookFolderPath === absolutePath ? persistedAbsolutePath : dirname(persistedAbsolutePath); + const persistedRelPath = relative(folder.path, persistedAbsolutePath); + destinationPath = persistedAbsolutePath; + const { bookId, created } = await this.processor.createBookRecord( libraryId, folder.id, - bookFolderPath, - absolutePath, - relPath, + persistedBookFolderPath, + persistedAbsolutePath, + persistedRelPath, format, sizeBytes, ); if (created) { - this.processor.processNewBookImportAsync(bookId, libraryId, absolutePath, format); + this.processor.processNewBookImportAsync(bookId, libraryId, persistedAbsolutePath, format); } else { - this.processor.extractMetadataAsync(bookId, absolutePath, format); + this.processor.extractMetadataAsync(bookId, persistedAbsolutePath, format); } this.logger.log( `[${event}] [end] libraryId=${libraryId} userId=${user.id} folderId=${folder.id} bookId=${bookId} format=${format} sizeBytes=${sizeBytes} durationMs=${Date.now() - startedAt} - upload completed`, ); - return { bookId, filename: basename(absolutePath), format, sizeBytes }; + return { bookId, filename: basename(persistedAbsolutePath), format, sizeBytes }; } catch (err) { const { errorClass, errorMessage } = this.parseError(err); this.logger.error( diff --git a/server/test/scanner-file-operations.e2e-spec.ts b/server/test/scanner-file-operations.e2e-spec.ts index 3e5f71679..b733961a2 100644 --- a/server/test/scanner-file-operations.e2e-spec.ts +++ b/server/test/scanner-file-operations.e2e-spec.ts @@ -151,6 +151,23 @@ const structuralScenarios: StructuralScenario[] = [ }, }, }, + { + id: 'manual-case-only-author-folder-rename', + trigger: 'manual', + libraries: [{ key: 'a', rootDir: 'lib-a', mode: 'book_per_folder' }], + entries: [file('lib-a/Bell Hooks/Book/book.epub')], + operations: [{ type: 'move', from: 'lib-a/Bell Hooks', to: 'lib-a/bell hooks' }], + expected: { + a: { + statusByFolder: { 'bell hooks/Book': 'present' }, + absentFolders: ['Bell Hooks/Book'], + fileOwners: { 'bell hooks/Book/book.epub': 'bell hooks/Book' }, + presentCount: 1, + missingCount: 0, + }, + }, + requiresCaseRename: true, + }, { id: 'manual-move-root-file-into-subfolder', trigger: 'manual',