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
This commit is contained in:
neon
2026-08-16 00:29:32 +00:00
parent f49541cb20
commit e2aeaf95ea
14 changed files with 528 additions and 75 deletions
@@ -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<string, string[]>): 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();
});
});
@@ -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();
});
});
@@ -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<string | null> {
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<boolean> {
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;
}
}
@@ -1,17 +1,21 @@
vi.mock('fs/promises', () => ({ vi.mock('fs/promises', () => ({
access: vi.fn(), access: vi.fn(),
lstat: vi.fn(),
readdir: vi.fn(),
readFile: vi.fn(), readFile: vi.fn(),
stat: vi.fn(), stat: vi.fn(),
unlink: vi.fn(), unlink: vi.fn(),
})); }));
import { BadRequestException, NotFoundException } from '@nestjs/common'; 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 type { BookDockMetadata } from '@bookorbit/types';
import { BookDockFinalizeService } from './book-dock-finalize.service'; import { BookDockFinalizeService } from './book-dock-finalize.service';
const mockAccess = vi.mocked(access); const mockAccess = vi.mocked(access);
const mockLstat = vi.mocked(lstat);
const mockReaddir = vi.mocked(readdir);
const mockReadFile = vi.mocked(readFile); const mockReadFile = vi.mocked(readFile);
const mockStat = vi.mocked(stat); const mockStat = vi.mocked(stat);
const mockUnlink = vi.mocked(unlink); const mockUnlink = vi.mocked(unlink);
@@ -148,7 +152,11 @@ describe('BookDockFinalizeService', () => {
mockReadFile.mockReset(); mockReadFile.mockReset();
mockStat.mockReset(); mockStat.mockReset();
mockUnlink.mockReset(); mockUnlink.mockReset();
mockLstat.mockReset();
mockReaddir.mockReset();
mockAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); 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')); mockReadFile.mockResolvedValue(Buffer.from('cover-bytes'));
mockStat.mockResolvedValue({ size: 100 } as never); mockStat.mockResolvedValue({ size: 100 } as never);
mockUnlink.mockResolvedValue(undefined); mockUnlink.mockResolvedValue(undefined);
@@ -34,6 +34,7 @@ import { NotificationService } from '../notification/notification.service';
import { SeriesIdentityService } from '../../common/services/series-identity.service'; import { SeriesIdentityService } from '../../common/services/series-identity.service';
import { SeriesMembershipService } from '../../common/services/series-membership.service'; import { SeriesMembershipService } from '../../common/services/series-membership.service';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; 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 { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils';
import { formatSeriesIndex } from '../../common/utils/series-index-format.utils'; import { formatSeriesIndex } from '../../common/utils/series-index-format.utils';
import { DB } from '../../db'; import { DB } from '../../db';
@@ -289,17 +290,18 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
} }
await this.storage.moveToPath(row.absolutePath, destPath); await this.storage.moveToPath(row.absolutePath, destPath);
const persistedDestPath = (await resolveExistingPathSpelling(destPath, folder.path)) ?? destPath;
let bookId: number; let bookId: number;
try { try {
const { size } = await stat(destPath); const { size } = await stat(persistedDestPath);
const bookFolderPath = library.organizationMode === 'book_per_file' ? destPath : dirname(destPath); const bookFolderPath = library.organizationMode === 'book_per_file' ? persistedDestPath : dirname(persistedDestPath);
({ bookId } = await this.processor.createBookRecord( ({ bookId } = await this.processor.createBookRecord(
library.id, library.id,
folder.id, folder.id,
bookFolderPath, bookFolderPath,
destPath, persistedDestPath,
destPath.substring(folder.path.length + 1), persistedDestPath.substring(folder.path.length + 1),
format, format,
size, size,
)); ));
@@ -311,8 +313,9 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
await this.cleanupBookDockRecord(row); await this.cleanupBookDockRecord(row);
existingDestinations.set(this.destinationKey(library.id, destPath), bookId); 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 }; return { fileId: row.id, fileName: row.fileName, newName, success: true, bookId };
} catch (err) { } catch (err) {
const message = resolveFinalizeErrorMessage(err); const message = resolveFinalizeErrorMessage(err);
@@ -1,34 +1,33 @@
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NotificationType } from '@bookorbit/types'; import { NotificationType } from '@bookorbit/types';
import type { MockedFunction } from 'vitest'; 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 { SelfWriteRegistry } from '../../common/services/self-write-registry.service';
import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils';
vi.mock('fs/promises', async () => { vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises'); const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises');
return { return {
...actual, ...actual,
access: vi.fn(), access: vi.fn(),
lstat: vi.fn(),
mkdir: vi.fn(), mkdir: vi.fn(),
readdir: vi.fn(), readdir: vi.fn(),
realpath: vi.fn(),
rename: vi.fn(), rename: vi.fn(),
rmdir: vi.fn(), rmdir: vi.fn(),
}; };
}); });
vi.mock('../../common/utils/path-identity.utils', () => ({ pathsReferToSameEntry: vi.fn() }));
import type { BookRenameData } from './file-rename.repository'; import type { BookRenameData } from './file-rename.repository';
import { FileRenameService } from './file-rename.service'; import { FileRenameService } from './file-rename.service';
import { bookOperationLockKey } from './file-lock.service'; import { bookOperationLockKey } from './file-lock.service';
const mockAccess = access as MockedFunction<typeof access>; const mockAccess = access as MockedFunction<typeof access>;
const mockLstat = lstat as MockedFunction<typeof lstat>;
const mockMkdir = mkdir as MockedFunction<typeof mkdir>; const mockMkdir = mkdir as MockedFunction<typeof mkdir>;
const mockReaddir = readdir as MockedFunction<typeof readdir>; const mockReaddir = readdir as MockedFunction<typeof readdir>;
const mockRealpath = realpath as MockedFunction<typeof realpath>;
const mockRename = fsRename as MockedFunction<typeof fsRename>; const mockRename = fsRename as MockedFunction<typeof fsRename>;
const mockRmdir = rmdir as MockedFunction<typeof rmdir>; const mockRmdir = rmdir as MockedFunction<typeof rmdir>;
const mockPathsReferToSameEntry = pathsReferToSameEntry as MockedFunction<typeof pathsReferToSameEntry>;
type RenameDataOverrides = Partial<BookRenameData> & { type RenameDataOverrides = Partial<BookRenameData> & {
file?: Partial<BookRenameData['file']>; file?: Partial<BookRenameData['file']>;
@@ -127,19 +126,16 @@ describe('FileRenameService', () => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.useRealTimers(); vi.useRealTimers();
mockAccess.mockReset(); mockAccess.mockReset();
mockLstat.mockReset();
mockMkdir.mockReset(); mockMkdir.mockReset();
mockReaddir.mockReset(); mockReaddir.mockReset();
mockRealpath.mockReset();
mockRename.mockReset(); mockRename.mockReset();
mockRmdir.mockReset(); mockRmdir.mockReset();
mockAccess.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }) as never); 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); mockMkdir.mockResolvedValue(undefined as never);
mockReaddir.mockResolvedValue(['still-here'] as never); mockReaddir.mockResolvedValue(['still-here'] as never);
mockRealpath.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }) as never);
mockRename.mockResolvedValue(undefined as never); mockRename.mockResolvedValue(undefined as never);
mockRmdir.mockResolvedValue(undefined as never); mockRmdir.mockResolvedValue(undefined as never);
mockPathsReferToSameEntry.mockImplementation((firstPath, secondPath) => Promise.resolve(firstPath === secondPath));
}); });
afterEach(() => { afterEach(() => {
@@ -759,8 +755,7 @@ describe('FileRenameService', () => {
if (path.toString() === '/library/Frank Herbert/Dune.epub') return Promise.resolve(undefined); if (path.toString() === '/library/Frank Herbert/Dune.epub') return Promise.resolve(undefined);
return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' }));
}); });
mockLstat.mockResolvedValue({ dev: 1, ino: 42 } as never); mockPathsReferToSameEntry.mockResolvedValue(true);
mockRealpath.mockResolvedValue('/library/Frank Herbert/Dune.epub');
const result = await service.performRename(5, 12); const result = await service.performRename(5, 12);
@@ -787,9 +782,6 @@ describe('FileRenameService', () => {
}), }),
); );
mockAccess.mockResolvedValue(undefined as never); 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); const result = await service.performRename(5, 12);
expect(result).toEqual(expect.objectContaining({ status: 'skipped', reason: 'target path already exists on disk' })); 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); if (path.toString() === '/library/Frank Herbert/Dune') return Promise.resolve(undefined);
return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' }));
}); });
mockLstat.mockResolvedValue({ dev: 1, ino: 42 } as never); mockPathsReferToSameEntry.mockResolvedValue(true);
mockRealpath.mockResolvedValue('/library/Frank Herbert/Dune');
const result = await service.performRename(5, 12); const result = await service.performRename(5, 12);
@@ -877,14 +868,6 @@ describe('FileRenameService', () => {
if (path.toString() === '/library/Bad_ Love.epub') return Promise.resolve(undefined); if (path.toString() === '/library/Bad_ Love.epub') return Promise.resolve(undefined);
return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); 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) => { mockRename.mockImplementation((source: any) => {
if (source.toString() === '/library/Bad: Love.epub') return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' })); if (source.toString() === '/library/Bad: Love.epub') return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' }));
return Promise.resolve(); return Promise.resolve();
@@ -1,12 +1,13 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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 { basename, dirname, extname, isAbsolute, join, normalize, relative, sep } from 'path';
import type { FileRenameResult } from '@bookorbit/types'; import type { FileRenameResult } from '@bookorbit/types';
import { isAudioFormat, NotificationType, resolveUploadPath, sanitizePathSegment } from '@bookorbit/types'; import { isAudioFormat, NotificationType, resolveUploadPath, sanitizePathSegment } from '@bookorbit/types';
import { SelfWriteRegistry } from '../../common/services/self-write-registry.service'; import { SelfWriteRegistry } from '../../common/services/self-write-registry.service';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; 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 { AppSettingsService } from '../app-settings/app-settings.service';
import { NotificationService } from '../notification/notification.service'; import { NotificationService } from '../notification/notification.service';
import type { BookFilePathUpdate, BookRenameData } from './file-rename.repository'; import type { BookFilePathUpdate, BookRenameData } from './file-rename.repository';
@@ -709,31 +710,17 @@ export class FileRenameService implements OnModuleDestroy {
return null; return null;
} }
private async pathsReferToSameEntry(firstPath: string, secondPath: string): Promise<boolean> {
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( private async pathsReferToSameSource(
sourcePath: string, sourcePath: string,
targetPath: string, targetPath: string,
libraryFolderPath: string, libraryFolderPath: string,
sanitizeForCrossPlatform: boolean, sanitizeForCrossPlatform: boolean,
): Promise<boolean> { ): Promise<boolean> {
if (await this.pathsReferToSameEntry(sourcePath, targetPath)) return true; if (await pathsReferToSameEntry(sourcePath, targetPath)) return true;
if (!sanitizeForCrossPlatform) return false; if (!sanitizeForCrossPlatform) return false;
const sanitizedSourcePath = this.buildSanitizedSourcePath(sourcePath, libraryFolderPath); 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<string> { private async renamePath(sourcePath: string, targetPath: string, libraryFolderPath: string, sanitizeForCrossPlatform: boolean): Promise<string> {
@@ -6,14 +6,15 @@ import { Logger } from '@nestjs/common';
vi.mock('fs/promises', () => ({ vi.mock('fs/promises', () => ({
stat: vi.fn(), stat: vi.fn(),
readdir: 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<typeof stat>; const mockStat = stat as MockedFunction<typeof stat>;
const mockReaddir = readdir as MockedFunction<typeof readdir>; const mockReaddir = readdir as MockedFunction<typeof readdir>;
const mockRealpath = realpath as MockedFunction<typeof realpath>; const mockPathsReferToSameEntry = pathsReferToSameEntry as MockedFunction<typeof pathsReferToSameEntry>;
const mockRepo: Mocked< const mockRepo: Mocked<
Pick< Pick<
@@ -99,6 +100,7 @@ beforeEach(() => {
mockRepo.findLibraryFolderPath.mockResolvedValue('/books'); mockRepo.findLibraryFolderPath.mockResolvedValue('/books');
mockRepo.findPresentDuplicateBookFilesByHash.mockResolvedValue([]); mockRepo.findPresentDuplicateBookFilesByHash.mockResolvedValue([]);
mockRepo.replaceDuplicateBookWithMovedBook.mockResolvedValue(null); mockRepo.replaceDuplicateBookWithMovedBook.mockResolvedValue(null);
mockPathsReferToSameEntry.mockResolvedValue(false);
mockReaddir.mockResolvedValue([]); mockReaddir.mockResolvedValue([]);
mockRepo.updateBookPrimaryFile.mockResolvedValue(undefined); mockRepo.updateBookPrimaryFile.mockResolvedValue(undefined);
}); });
@@ -681,7 +683,7 @@ describe('handleCreate — move detection', () => {
const newPath = '/books/Author/book.epub'; const newPath = '/books/Author/book.epub';
const fileStat = makeFileStat({ ino: 4001n, size: 70000 }); const fileStat = makeFileStat({ ino: 4001n, size: 70000 });
mockStat.mockResolvedValue(fileStat); mockStat.mockResolvedValue(fileStat);
mockRealpath.mockResolvedValue(newPath); mockPathsReferToSameEntry.mockResolvedValue(true);
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null); mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
mockRepo.findMissingBookByFolderPath.mockResolvedValue(null); mockRepo.findMissingBookByFolderPath.mockResolvedValue(null);
mockRepo.findBookFileWithContextByIno.mockResolvedValue({ mockRepo.findBookFileWithContextByIno.mockResolvedValue({
@@ -1,8 +1,9 @@
import { Injectable, Logger } from '@nestjs/common'; 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 type { BigIntStats } from 'fs';
import { dirname, join, relative } from 'path'; import { dirname, join, relative } from 'path';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; 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 { classifyFile, DEFAULT_FORMAT_PRIORITY } from './lib/classify';
import { ScannerRepository } from './scanner.repository'; import { ScannerRepository } from './scanner.repository';
@@ -333,9 +334,8 @@ export class FileEventProcessorService {
(s) => s.isFile(), (s) => s.isFile(),
() => false, () => false,
); );
if (oldFileStillExists && file.absolutePath.toLowerCase() === newAbsolutePath.toLowerCase()) { if (oldFileStillExists && (await pathsReferToSameEntry(file.absolutePath, newAbsolutePath))) {
const canonicalPath = await realpath(file.absolutePath).catch(() => null); oldFileStillExists = false;
oldFileStillExists = canonicalPath !== newAbsolutePath;
} }
if (oldFileStillExists) { if (oldFileStillExists) {
this.logger.log( this.logger.log(
@@ -1,6 +1,7 @@
vi.mock('./lib/walk'); vi.mock('./lib/walk');
vi.mock('./lib/hash'); vi.mock('./lib/hash');
vi.mock('./lib/stability', () => ({ waitForStability: vi.fn().mockResolvedValue(undefined) })); 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) => { vi.mock('fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs/promises')>(); const actual = await importOriginal<typeof import('fs/promises')>();
return { return {
@@ -23,12 +24,14 @@ import { DEFAULT_FORMAT_PRIORITY } from './lib/classify';
import type { BookCandidate, FileStat } from './lib/walk'; import type { BookCandidate, FileStat } from './lib/walk';
import { findBookCandidates, findLooseFileCandidates, buildSingleBookCandidate } from './lib/walk'; import { findBookCandidates, findLooseFileCandidates, buildSingleBookCandidate } from './lib/walk';
import { computeFileHash } from './lib/hash'; import { computeFileHash } from './lib/hash';
import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils';
import * as assembleBookCardsModule from '../book/utils/assemble-book-cards'; import * as assembleBookCardsModule from '../book/utils/assemble-book-cards';
const mockFindCandidates = findBookCandidates as MockedFunction<typeof findBookCandidates>; const mockFindCandidates = findBookCandidates as MockedFunction<typeof findBookCandidates>;
const mockFindLooseCandidates = findLooseFileCandidates as MockedFunction<typeof findLooseFileCandidates>; const mockFindLooseCandidates = findLooseFileCandidates as MockedFunction<typeof findLooseFileCandidates>;
const mockBuildSingleCandidate = buildSingleBookCandidate as MockedFunction<typeof buildSingleBookCandidate>; const mockBuildSingleCandidate = buildSingleBookCandidate as MockedFunction<typeof buildSingleBookCandidate>;
const mockFingerprint = computeFileHash as MockedFunction<typeof computeFileHash>; const mockFingerprint = computeFileHash as MockedFunction<typeof computeFileHash>;
const mockPathsReferToSameEntry = pathsReferToSameEntry as MockedFunction<typeof pathsReferToSameEntry>;
const mockReaddir = readdir as MockedFunction<typeof readdir>; const mockReaddir = readdir as MockedFunction<typeof readdir>;
const mockStat = stat as MockedFunction<typeof stat>; const mockStat = stat as MockedFunction<typeof stat>;
@@ -190,6 +193,7 @@ beforeEach(() => {
mockFindLooseCandidates.mockResolvedValue({ candidates: [], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() }); mockFindLooseCandidates.mockResolvedValue({ candidates: [], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
mockBuildSingleCandidate.mockResolvedValue(null); mockBuildSingleCandidate.mockResolvedValue(null);
mockFingerprint.mockResolvedValue('hash-abc'); mockFingerprint.mockResolvedValue('hash-abc');
mockPathsReferToSameEntry.mockResolvedValue(false);
mockReaddir.mockResolvedValue([]); mockReaddir.mockResolvedValue([]);
mockStat.mockResolvedValue({ isFile: () => true, ino: 2001n, size: 1024, mtime: new Date('2024-01-01') } as any); mockStat.mockResolvedValue({ isFile: () => true, ino: 2001n, size: 1024, mtime: new Date('2024-01-01') } as any);
delete (mockMetadata as Record<string, unknown>).extractAndSaveIfAvailable; delete (mockMetadata as Record<string, unknown>).extractAndSaveIfAvailable;
@@ -973,6 +977,51 @@ describe('file identity resolution', () => {
expect(repo.createBookFile).not.toHaveBeenCalled(); 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 () => { it('reuses an existing book id when a MergerFS folder is renamed via an exact oversized inode match', async () => {
const renamedFile = makeFileStat({ const renamedFile = makeFileStat({
absolutePath: '/library/Author/NewName/book.epub', absolutePath: '/library/Author/NewName/book.epub',
@@ -1809,6 +1858,57 @@ describe('cross-library transfer', () => {
expect(mockGateway.emitBookTransferred).toHaveBeenCalledWith({ fromLibraryId: 1, toLibraryId: 2, bookIds: [55] }); 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 () => { it('transfers a missing source book via hash fallback when inode differs', async () => {
const destinationFile = makeFileStat({ const destinationFile = makeFileStat({
absolutePath: '/dest/Inbox/book.epub', absolutePath: '/dest/Inbox/book.epub',
+26 -11
View File
@@ -1,6 +1,7 @@
import { ConflictException, Injectable, Logger, NotFoundException, OnApplicationBootstrap, Optional } from '@nestjs/common'; import { ConflictException, Injectable, Logger, NotFoundException, OnApplicationBootstrap, Optional } from '@nestjs/common';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils';
import type { import type {
BookMissingEvent, BookMissingEvent,
@@ -1821,7 +1822,8 @@ export class ScannerService implements OnApplicationBootstrap {
const sourceBook = this.findBookEntryById(bookByFolderPath, sourceBookId); const sourceBook = this.findBookEntryById(bookByFolderPath, sourceBookId);
if (!sourceBook) return null; if (!sourceBook) return null;
if (sourceBook.folderPath === candidate.folderPath) return sourceBook; 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); 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<boolean> {
if (!(await this.pathExists(existingPath))) return false;
return !(await pathsReferToSameEntry(existingPath, candidatePath));
}
private async fileExistsAsDistinctEntry(existingPath: string, candidatePath: string): Promise<boolean> {
const existingStat = await stat(existingPath).catch(() => null);
if (!existingStat?.isFile()) return false;
return !(await pathsReferToSameEntry(existingPath, candidatePath));
}
private async filterBookIdsMissingOnDisk(bookIds: number[]): Promise<number[]> { private async filterBookIdsMissingOnDisk(bookIds: number[]): Promise<number[]> {
if (bookIds.length === 0) return []; if (bookIds.length === 0) return [];
@@ -1915,7 +1928,7 @@ export class ScannerService implements OnApplicationBootstrap {
for (const file of contentFiles) { for (const file of contentFiles) {
if (file.ino === 0n) continue; if (file.ino === 0n) continue;
const byIno = await this.scannerRepo.findMissingBookFileWithContextByIno(file.ino); 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; sourceBookId = byIno.file.bookId;
sourceLibraryId = byIno.libraryId; sourceLibraryId = byIno.libraryId;
break; break;
@@ -1926,8 +1939,7 @@ export class ScannerService implements OnApplicationBootstrap {
if (file.ino === 0n) continue; if (file.ino === 0n) continue;
const byIno = await this.scannerRepo.findBookFileWithContextByIno(file.ino); const byIno = await this.scannerRepo.findBookFileWithContextByIno(file.ino);
if (!byIno || byIno.file.absolutePath === file.absolutePath) continue; if (!byIno || byIno.file.absolutePath === file.absolutePath) continue;
const previousPathStat = await stat(byIno.file.absolutePath).catch(() => null); if (await this.fileExistsAsDistinctEntry(byIno.file.absolutePath, file.absolutePath)) continue;
if (previousPathStat?.isFile()) continue;
sourceBookId = byIno.file.bookId; sourceBookId = byIno.file.bookId;
sourceLibraryId = byIno.libraryId; sourceLibraryId = byIno.libraryId;
break; break;
@@ -1946,7 +1958,11 @@ export class ScannerService implements OnApplicationBootstrap {
} }
const byHash = await this.scannerRepo.findMissingBookFileWithContextByHash(fileHash); 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; sourceBookId = byHash.file.bookId;
sourceLibraryId = byHash.libraryId; sourceLibraryId = byHash.libraryId;
break; break;
@@ -1954,8 +1970,7 @@ export class ScannerService implements OnApplicationBootstrap {
const byHashAny = await this.scannerRepo.findBookFileWithContextByHash(fileHash); const byHashAny = await this.scannerRepo.findBookFileWithContextByHash(fileHash);
if (!byHashAny || byHashAny.file.absolutePath === file.absolutePath || byHashAny.file.sizeBytes !== file.sizeBytes) continue; if (!byHashAny || byHashAny.file.absolutePath === file.absolutePath || byHashAny.file.sizeBytes !== file.sizeBytes) continue;
const previousPathStat = await stat(byHashAny.file.absolutePath).catch(() => null); if (await this.fileExistsAsDistinctEntry(byHashAny.file.absolutePath, file.absolutePath)) continue;
if (previousPathStat?.isFile()) continue;
sourceBookId = byHashAny.file.bookId; sourceBookId = byHashAny.file.bookId;
sourceLibraryId = byHashAny.libraryId; sourceLibraryId = byHashAny.libraryId;
break; break;
@@ -2109,7 +2124,7 @@ export class ScannerService implements OnApplicationBootstrap {
if (!byIno) return null; if (!byIno) return null;
const oldAbsolutePath = byIno.absolutePath; 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 sizeUnchanged = fileStat.sizeBytes === byIno.sizeBytes;
const mtimeUnchanged = fileStat.mtime.getTime() === byIno.mtime?.getTime(); const mtimeUnchanged = fileStat.mtime.getTime() === byIno.mtime?.getTime();
await this.scannerRepo.updateBookFile(byIno.id, { await this.scannerRepo.updateBookFile(byIno.id, {
@@ -2168,7 +2183,7 @@ export class ScannerService implements OnApplicationBootstrap {
) { ) {
return null; 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 oldAbsolutePath = globalByIno.file.absolutePath;
const sizeUnchanged = fileStat.sizeBytes === globalByIno.file.sizeBytes; const sizeUnchanged = fileStat.sizeBytes === globalByIno.file.sizeBytes;
@@ -2246,7 +2261,7 @@ export class ScannerService implements OnApplicationBootstrap {
if (!isFirstScan) { if (!isFirstScan) {
const byHash = await this.scannerRepo.findBookFileByHash(fileHash, libraryFolderId); 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; const oldAbsolutePath = byHash.absolutePath;
await this.scannerRepo.updateBookFile(byHash.id, { await this.scannerRepo.updateBookFile(byHash.id, {
bookId, bookId,
@@ -2304,7 +2319,7 @@ export class ScannerService implements OnApplicationBootstrap {
globalByHash.file.absolutePath !== fileStat.absolutePath && globalByHash.file.absolutePath !== fileStat.absolutePath &&
globalByHash.file.sizeBytes === fileStat.sizeBytes && globalByHash.file.sizeBytes === fileStat.sizeBytes &&
(globalByHash.file.bookId === bookId || globalByHash.bookStatus === 'missing') && (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; const oldAbsolutePath = globalByHash.file.absolutePath;
await this.scannerRepo.updateBookFile(globalByHash.file.id, { await this.scannerRepo.updateBookFile(globalByHash.file.id, {
@@ -1,6 +1,6 @@
vi.mock('fs/promises', async () => { vi.mock('fs/promises', async () => {
const actual = await vi.importActual('fs/promises'); 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() })); 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/mobi-parser', () => ({ parseMobiFile: vi.fn() }));
vi.mock('../metadata/lib/pdf-parser', () => ({ parsePdfFile: 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 { BadRequestException, ConflictException, ForbiddenException, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { extractEpubMetadata } from '../metadata/lib/epub'; import { extractEpubMetadata } from '../metadata/lib/epub';
import { extractCbzMetadata } from '../metadata/lib/cbz-metadata'; import { extractCbzMetadata } from '../metadata/lib/cbz-metadata';
@@ -22,6 +22,8 @@ import { books } from '../../db/schema';
import { UploadService } from './upload.service'; import { UploadService } from './upload.service';
const mockFsAccess = fsAccess as MockedFunction<typeof fsAccess>; const mockFsAccess = fsAccess as MockedFunction<typeof fsAccess>;
const mockLstat = lstat as MockedFunction<typeof lstat>;
const mockReaddir = readdir as MockedFunction<typeof readdir>;
const mockStat = stat as MockedFunction<typeof stat>; const mockStat = stat as MockedFunction<typeof stat>;
const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction<typeof extractEpubMetadata>; const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction<typeof extractEpubMetadata>;
const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction<typeof extractCbzMetadata>; const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction<typeof extractCbzMetadata>;
@@ -133,6 +135,8 @@ describe('UploadService', () => {
appSettings.getUploadPatternBookPerFolder.mockResolvedValue(null); appSettings.getUploadPatternBookPerFolder.mockResolvedValue(null);
appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(false); appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(false);
mockFsAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); 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); mockExtractEpubMetadata.mockResolvedValue(null);
// addFileToBook defaults // addFileToBook defaults
@@ -201,6 +205,50 @@ describe('UploadService', () => {
expect(processor.processNewBookImportAsync).toHaveBeenCalledWith(99, 1, '/library/Frank Herbert/Dune.epub', 'epub'); 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<string, string[]> = {
'/': ['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 () => { it('only extracts metadata when an upload adds a file to an existing book', async () => {
processor.createBookRecord.mockResolvedValue({ bookId: 99, created: false }); processor.createBookRecord.mockResolvedValue({ bookId: 99, created: false });
db.select db.select
+13 -7
View File
@@ -25,6 +25,7 @@ import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../m
import { parseMobiFile } from '../metadata/lib/mobi-parser'; import { parseMobiFile } from '../metadata/lib/mobi-parser';
import { parsePdfFile, type PdfParseWarning } from '../metadata/lib/pdf-parser'; import { parsePdfFile, type PdfParseWarning } from '../metadata/lib/pdf-parser';
import { computeFileHash } from '../scanner/lib/hash'; import { computeFileHash } from '../scanner/lib/hash';
import { resolveExistingPathSpelling } from '../../common/utils/path-identity.utils';
type Db = NodePgDatabase<typeof schema>; type Db = NodePgDatabase<typeof schema>;
@@ -73,7 +74,7 @@ export class UploadService {
let shouldCleanupDestination = false; let shouldCleanupDestination = false;
try { 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; destinationPath = absolutePath;
if (await this.destinationExists(absolutePath)) { if (await this.destinationExists(absolutePath)) {
@@ -83,26 +84,31 @@ export class UploadService {
shouldCleanupDestination = true; shouldCleanupDestination = true;
await this.storage.moveToPath(tempPath, absolutePath); 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( const { bookId, created } = await this.processor.createBookRecord(
libraryId, libraryId,
folder.id, folder.id,
bookFolderPath, persistedBookFolderPath,
absolutePath, persistedAbsolutePath,
relPath, persistedRelPath,
format, format,
sizeBytes, sizeBytes,
); );
if (created) { if (created) {
this.processor.processNewBookImportAsync(bookId, libraryId, absolutePath, format); this.processor.processNewBookImportAsync(bookId, libraryId, persistedAbsolutePath, format);
} else { } else {
this.processor.extractMetadataAsync(bookId, absolutePath, format); this.processor.extractMetadataAsync(bookId, persistedAbsolutePath, format);
} }
this.logger.log( 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`, `[${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) { } catch (err) {
const { errorClass, errorMessage } = this.parseError(err); const { errorClass, errorMessage } = this.parseError(err);
this.logger.error( this.logger.error(
@@ -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', id: 'manual-move-root-file-into-subfolder',
trigger: 'manual', trigger: 'manual',