mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(scanner): support bigint inodes to prevent folder mis-merges
Store file inodes as numeric rather than bigint on PostgreSQL, and read/process them as BigInt in Node.js. This preserves exact 64-bit inodes from filesystems like MergerFS (hybrid-hash) and avoids folder mis-merging or disabled rename detection. Closes #618
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TABLE "book_files" ALTER COLUMN "ino" SET DATA TYPE numeric(20, 0);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -337,6 +337,13 @@
|
||||
"when": 1783791670862,
|
||||
"tag": "0047_accent-insensitive-search-indexes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 48,
|
||||
"version": "7",
|
||||
"when": 1783800042730,
|
||||
"tag": "0048_preserve-bigint-inodes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { bigint, check, foreignKey, index, integer, pgTable, serial, timestamp, unique, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
import { bigint, check, foreignKey, index, integer, numeric, pgTable, serial, timestamp, unique, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { libraryFolders, libraries } from './libraries';
|
||||
|
||||
@@ -59,7 +59,7 @@ export const bookFiles = pgTable(
|
||||
.references(() => libraryFolders.id, { onDelete: 'cascade' }),
|
||||
absolutePath: varchar('absolute_path', { length: 4096 }).notNull(),
|
||||
relPath: varchar('rel_path', { length: 4096 }),
|
||||
ino: bigint('ino', { mode: 'number' }).notNull(),
|
||||
ino: numeric('ino', { precision: 20, scale: 0, mode: 'bigint' }).notNull(),
|
||||
sizeBytes: bigint('size_bytes', { mode: 'number' }),
|
||||
mtime: timestamp('mtime', { withTimezone: true }),
|
||||
fileHash: varchar('file_hash', { length: 32 }),
|
||||
|
||||
@@ -64,7 +64,7 @@ function makeService() {
|
||||
return new FileEventProcessorService(mockRepo as unknown as ScannerRepository);
|
||||
}
|
||||
|
||||
function makeFileStat(overrides: Partial<{ ino: number | bigint; size: number | bigint; mtime: Date; isDirectory: boolean; isFile: boolean }> = {}) {
|
||||
function makeFileStat(overrides: Partial<{ ino: bigint; size: number | bigint; mtime: Date; isDirectory: boolean; isFile: boolean }> = {}) {
|
||||
return {
|
||||
ino: overrides.ino ?? 1001n,
|
||||
size: overrides.size ?? 12345n,
|
||||
@@ -393,7 +393,7 @@ describe('handleCreate — file', () => {
|
||||
});
|
||||
|
||||
it('restores book by updating existing file row when own book is missing', async () => {
|
||||
const fileStat = makeFileStat({ ino: 2000, size: 50000 });
|
||||
const fileStat = makeFileStat({ ino: 2000n, size: 50000 });
|
||||
mockStat.mockResolvedValue(fileStat);
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
|
||||
file: { id: 42, bookId: 10 },
|
||||
@@ -403,14 +403,14 @@ describe('handleCreate — file', () => {
|
||||
|
||||
const result = await makeService().handleCreate('/books/Author/book.epub');
|
||||
|
||||
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(42, expect.objectContaining({ ino: 2000, sizeBytes: 50000 }));
|
||||
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(42, expect.objectContaining({ ino: 2000n, sizeBytes: 50000 }));
|
||||
expect(mockRepo.updateBookPrimaryFile).toHaveBeenCalled();
|
||||
expect(mockRepo.markBooksAsPresent).toHaveBeenCalledWith([10]);
|
||||
expect(mockRepo.createBookFile).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ type: 'book-restored', libraryId: 3, bookIds: [10] });
|
||||
});
|
||||
|
||||
it('clamps oversized MergerFS inode values to 0 when restoring existing file rows', async () => {
|
||||
it('preserves oversized MergerFS inode values when restoring existing file rows', async () => {
|
||||
const fileStat = makeFileStat({ ino: 14351917807348929000n, size: 50000n });
|
||||
mockStat.mockResolvedValue(fileStat);
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
|
||||
@@ -421,10 +421,10 @@ describe('handleCreate — file', () => {
|
||||
|
||||
await makeService().handleCreate('/books/Author/book.epub');
|
||||
|
||||
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(42, expect.objectContaining({ ino: 0 }));
|
||||
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(42, expect.objectContaining({ ino: 14351917807348929000n }));
|
||||
});
|
||||
|
||||
it('clamps precision-unsafe inodes to 0 when restoring existing file rows', async () => {
|
||||
it('preserves precision-unsafe inodes when restoring existing file rows', async () => {
|
||||
const fileStat = makeFileStat({ ino: 651896050678335552n, size: 50000n });
|
||||
mockStat.mockResolvedValue(fileStat);
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
|
||||
@@ -435,11 +435,11 @@ describe('handleCreate — file', () => {
|
||||
|
||||
await makeService().handleCreate('/books/Author/book.epub');
|
||||
|
||||
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(42, expect.objectContaining({ ino: 0 }));
|
||||
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(42, expect.objectContaining({ ino: 651896050678335552n }));
|
||||
});
|
||||
|
||||
it('restores own book before searching for folder-level missing books (Issue 25)', async () => {
|
||||
const fileStat = makeFileStat({ ino: 2000, size: 50000 });
|
||||
const fileStat = makeFileStat({ ino: 2000n, size: 50000 });
|
||||
mockStat.mockResolvedValue(fileStat);
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
|
||||
file: { id: 42, bookId: 10 },
|
||||
@@ -459,7 +459,7 @@ describe('handleCreate — file', () => {
|
||||
});
|
||||
|
||||
it('falls through to folder-level missing book when own book is present (Issue 25)', async () => {
|
||||
const fileStat = makeFileStat({ ino: 2000, size: 50000 });
|
||||
const fileStat = makeFileStat({ ino: 2000n, size: 50000 });
|
||||
mockStat.mockResolvedValue(fileStat);
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
|
||||
file: { id: 42, bookId: 10 },
|
||||
@@ -489,7 +489,7 @@ describe('handleCreate — file', () => {
|
||||
});
|
||||
|
||||
it('creates new file row when no existing row but missing book found via dirname', async () => {
|
||||
const fileStat = makeFileStat({ ino: 2000, size: 50000 });
|
||||
const fileStat = makeFileStat({ ino: 2000n, size: 50000 });
|
||||
mockStat.mockResolvedValue(fileStat);
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
|
||||
mockRepo.findMissingBookByFolderPath.mockResolvedValue({
|
||||
@@ -509,7 +509,7 @@ describe('handleCreate — file', () => {
|
||||
});
|
||||
|
||||
it('creates new file row for root-level book where folderPath equals file path', async () => {
|
||||
const fileStat = makeFileStat({ ino: 3000, size: 60000 });
|
||||
const fileStat = makeFileStat({ ino: 3000n, size: 60000 });
|
||||
mockStat.mockResolvedValue(fileStat);
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
|
||||
mockRepo.findMissingBookByFolderPath.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 60, libraryId: 5, libraryFolderId: 15 } as any);
|
||||
@@ -542,7 +542,7 @@ describe('handleCreate — directory (folder restoration)', () => {
|
||||
it('restores books by updating existing file rows when files exist on disk', async () => {
|
||||
mockStat
|
||||
.mockResolvedValueOnce(makeFileStat({ isDirectory: true, isFile: false }))
|
||||
.mockResolvedValueOnce(makeFileStat({ ino: 3000, size: 80000, isFile: true }));
|
||||
.mockResolvedValueOnce(makeFileStat({ ino: 3000n, size: 80000, isFile: true }));
|
||||
|
||||
mockRepo.findMissingBooksByFolderPath.mockResolvedValue([
|
||||
{ id: 70, libraryId: 6, libraryFolderId: 20, folderPath: '/books/Author/Title' } as any,
|
||||
@@ -553,7 +553,7 @@ describe('handleCreate — directory (folder restoration)', () => {
|
||||
|
||||
const result = await makeService().handleCreate('/books/Author/Title');
|
||||
|
||||
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(100, expect.objectContaining({ ino: 3000 }));
|
||||
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(100, expect.objectContaining({ ino: 3000n }));
|
||||
expect(mockRepo.markBooksAsPresent).toHaveBeenCalledWith([70]);
|
||||
expect(result).toEqual({ type: 'book-restored', libraryId: 6, bookIds: [70] });
|
||||
});
|
||||
@@ -577,7 +577,7 @@ describe('handleCreate — directory (folder restoration)', () => {
|
||||
|
||||
describe('handleCreate — move detection', () => {
|
||||
it('detects a moved file via inode match and updates paths', async () => {
|
||||
const fileStat = makeFileStat({ ino: 4000, size: 70000 });
|
||||
const fileStat = makeFileStat({ ino: 4000n, size: 70000 });
|
||||
// First call: stat for the new path; second call: stat for the old path (should not exist)
|
||||
mockStat.mockResolvedValueOnce(fileStat).mockRejectedValueOnce(new Error('ENOENT'));
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
|
||||
@@ -602,7 +602,7 @@ describe('handleCreate — move detection', () => {
|
||||
});
|
||||
|
||||
it('skips move detection when inode matches same path (not a move)', async () => {
|
||||
const fileStat = makeFileStat({ ino: 4000 });
|
||||
const fileStat = makeFileStat({ ino: 4000n });
|
||||
mockStat.mockResolvedValue(fileStat);
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
|
||||
mockRepo.findMissingBookByFolderPath.mockResolvedValue(null);
|
||||
@@ -621,7 +621,7 @@ describe('handleCreate — move detection', () => {
|
||||
});
|
||||
|
||||
it('does not update folderPath when folder did not change', async () => {
|
||||
const fileStat = makeFileStat({ ino: 4000, size: 70000 });
|
||||
const fileStat = makeFileStat({ ino: 4000n, size: 70000 });
|
||||
// First call: stat for the new path; second call: stat for the old path (should not exist)
|
||||
mockStat.mockResolvedValueOnce(fileStat).mockRejectedValueOnce(new Error('ENOENT'));
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
|
||||
@@ -641,7 +641,7 @@ describe('handleCreate — move detection', () => {
|
||||
});
|
||||
|
||||
it('handles root-level file rename (folderPath equals old absolutePath)', async () => {
|
||||
const fileStat = makeFileStat({ ino: 5000 });
|
||||
const fileStat = makeFileStat({ ino: 5000n });
|
||||
// First call: stat for the new path; second call: stat for the old path (should not exist)
|
||||
mockStat.mockResolvedValueOnce(fileStat).mockRejectedValueOnce(new Error('ENOENT'));
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
|
||||
@@ -661,7 +661,7 @@ describe('handleCreate — move detection', () => {
|
||||
});
|
||||
|
||||
it('keeps folderPath equal to the moved file path in book_per_file mode', async () => {
|
||||
const fileStat = makeFileStat({ ino: 5001 });
|
||||
const fileStat = makeFileStat({ ino: 5001n });
|
||||
mockStat.mockResolvedValueOnce(fileStat).mockRejectedValueOnce(new Error('ENOENT'));
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
|
||||
mockRepo.findMissingBookByFolderPath.mockResolvedValue(null);
|
||||
@@ -680,7 +680,7 @@ describe('handleCreate — move detection', () => {
|
||||
expect(result).toEqual({ type: 'book-moved', libraryId: 3, bookIds: [41] });
|
||||
});
|
||||
|
||||
it('skips inode-based move detection when inode is precision-unsafe in JavaScript', async () => {
|
||||
it('uses exact inode-based move detection when inode is precision-unsafe in JavaScript', async () => {
|
||||
const fileStat = makeFileStat({ ino: 651896050678335552n });
|
||||
mockStat.mockResolvedValue(fileStat);
|
||||
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
|
||||
@@ -688,7 +688,7 @@ describe('handleCreate — move detection', () => {
|
||||
|
||||
const result = await makeService().handleCreate('/books/Author/book.epub');
|
||||
|
||||
expect(mockRepo.findBookFileWithContextByIno).not.toHaveBeenCalled();
|
||||
expect(mockRepo.findBookFileWithContextByIno).toHaveBeenCalledWith(651896050678335552n, undefined);
|
||||
expect(result).toEqual({ type: 'noop' });
|
||||
});
|
||||
});
|
||||
@@ -699,7 +699,7 @@ describe('handleCreate — directory move detection', () => {
|
||||
it('detects moved books in a renamed directory via inode matching', async () => {
|
||||
mockStat
|
||||
.mockResolvedValueOnce(makeFileStat({ isDirectory: true, isFile: false }))
|
||||
.mockResolvedValueOnce(makeFileStat({ ino: 6000, size: 90000 }))
|
||||
.mockResolvedValueOnce(makeFileStat({ ino: 6000n, size: 90000 }))
|
||||
.mockRejectedValueOnce(new Error('ENOENT')); // old path no longer exists
|
||||
|
||||
mockRepo.findMissingBooksByFolderPath.mockResolvedValue([]);
|
||||
@@ -746,11 +746,11 @@ describe('reconcileMissingBooks', () => {
|
||||
mockRepo.findBookFilesByBookId.mockResolvedValue([
|
||||
{ id: 300, absolutePath: '/books/restored/book.epub', bookId: 50, role: 'content', format: 'epub', sizeBytes: 99000 } as any,
|
||||
]);
|
||||
mockStat.mockResolvedValue(makeFileStat({ ino: 5000, size: 99000 }));
|
||||
mockStat.mockResolvedValue(makeFileStat({ ino: 5000n, size: 99000 }));
|
||||
|
||||
const results = await makeService().reconcileMissingBooks([2]);
|
||||
|
||||
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(300, expect.objectContaining({ ino: 5000 }));
|
||||
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(300, expect.objectContaining({ ino: 5000n }));
|
||||
expect(mockRepo.updateBookPrimaryFile).toHaveBeenCalledWith(50, 300);
|
||||
expect(mockRepo.markBooksAsPresent).toHaveBeenCalledWith([50]);
|
||||
expect(results).toEqual([{ type: 'book-restored', libraryId: 2, bookIds: [50] }]);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { dirname, join, relative } from 'path';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
|
||||
import { classifyFile, DEFAULT_FORMAT_PRIORITY } from './lib/classify';
|
||||
import { clampIno } from './lib/walk';
|
||||
import { ScannerRepository } from './scanner.repository';
|
||||
|
||||
export type FileEventResult =
|
||||
@@ -156,8 +155,8 @@ export class FileEventProcessorService {
|
||||
// for the wrong book), check if the inode belongs to a file already tracked by a
|
||||
// book at this folder path. This handles atomic-save patterns where an editor writes
|
||||
// a new inode for the same logical file.
|
||||
const ino = clampIno(fileStat.ino);
|
||||
if (ino !== 0) {
|
||||
const ino = fileStat.ino;
|
||||
if (ino !== 0n) {
|
||||
const folderPath = dirname(absolutePath);
|
||||
const ownBook =
|
||||
(await this.scannerRepo.findMissingBookByFolderPath(folderPath, scopeLibraryId)) ??
|
||||
@@ -315,8 +314,8 @@ export class FileEventProcessorService {
|
||||
}
|
||||
|
||||
private async detectMovedFile(newAbsolutePath: string, fileStat: FsStat, scopeLibraryId?: number): Promise<FileEventResult> {
|
||||
const ino = clampIno(fileStat.ino);
|
||||
if (ino === 0) return { type: 'noop' };
|
||||
const ino = fileStat.ino;
|
||||
if (ino === 0n) return { type: 'noop' };
|
||||
|
||||
const match = await this.scannerRepo.findBookFileWithContextByIno(ino, scopeLibraryId);
|
||||
if (!match || match.file.absolutePath === newAbsolutePath) return { type: 'noop' };
|
||||
@@ -372,8 +371,8 @@ export class FileEventProcessorService {
|
||||
return { type: 'book-moved', libraryId: rowLibraryId, bookIds: [file.bookId] };
|
||||
}
|
||||
|
||||
private statToFileInfo(s: FsStat): { ino: number; sizeBytes: number; mtime: Date } {
|
||||
return { ino: clampIno(s.ino), sizeBytes: Number(s.size), mtime: s.mtime };
|
||||
private statToFileInfo(s: FsStat): { ino: bigint; sizeBytes: number; mtime: Date } {
|
||||
return { ino: s.ino, sizeBytes: Number(s.size), mtime: s.mtime };
|
||||
}
|
||||
|
||||
private async pathIsFile(path: string): Promise<boolean> {
|
||||
|
||||
+34
-32
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* Integration tests verifying that precision-unsafe inodes are clamped before matching.
|
||||
*
|
||||
* These tests mock `fs/promises.stat` to inject synthetic BigInt inode values that would
|
||||
* otherwise be lossy when converted to JavaScript numbers.
|
||||
*/
|
||||
/** Integration tests for exact inode handling across scanner walk entry points. */
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises');
|
||||
@@ -112,36 +107,35 @@ function setupStatMock(fileIno: bigint): void {
|
||||
|
||||
// ── findBookCandidates ────────────────────────────────────────────────────────
|
||||
|
||||
describe('findBookCandidates — oversized MergerFS inode', () => {
|
||||
it('sets ino=0 when stat returns an inode that is unsafe in JavaScript numbers', async () => {
|
||||
describe('findBookCandidates - exact MergerFS inode', () => {
|
||||
it('preserves an inode above the JavaScript safe integer range', async () => {
|
||||
await touchFile('Book/book.epub');
|
||||
setupStatMock(UNSAFE_INO);
|
||||
|
||||
const { candidates } = await findBookCandidates(root);
|
||||
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect(candidates[0].files[0].ino).toBe(0);
|
||||
expect(candidates[0].files[0].ino).toBe(UNSAFE_INO);
|
||||
});
|
||||
|
||||
it('sets ino=0 when stat returns an inode exceeding PostgreSQL bigint range', async () => {
|
||||
it('preserves an unsigned inode exceeding the PostgreSQL bigint range', async () => {
|
||||
await touchFile('Book/book.epub');
|
||||
setupStatMock(OVERSIZED_INO);
|
||||
|
||||
const { candidates } = await findBookCandidates(root);
|
||||
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect(candidates[0].files[0].ino).toBe(0);
|
||||
expect(candidates[0].files[0].ino).toBe(OVERSIZED_INO);
|
||||
});
|
||||
|
||||
it('emits a logger warning that mentions the oversized inode value', async () => {
|
||||
it('does not warn when an unsigned 64-bit inode can be matched exactly', async () => {
|
||||
await touchFile('Book/book.epub');
|
||||
setupStatMock(OVERSIZED_INO);
|
||||
|
||||
const warnings: string[] = [];
|
||||
await findBookCandidates(root, [], (msg) => warnings.push(msg));
|
||||
|
||||
expect(warnings.some((w) => w.includes(String(OVERSIZED_INO)))).toBe(true);
|
||||
expect(warnings.some((w) => w.includes('rename detection disabled'))).toBe(true);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('preserves normal inodes that are within PostgreSQL bigint range', async () => {
|
||||
@@ -150,16 +144,16 @@ describe('findBookCandidates — oversized MergerFS inode', () => {
|
||||
|
||||
const { candidates } = await findBookCandidates(root);
|
||||
|
||||
expect(candidates[0].files[0].ino).toBe(Number(NORMAL_INO));
|
||||
expect(candidates[0].files[0].ino).toBe(NORMAL_INO);
|
||||
});
|
||||
|
||||
it('sets ino=0 for the PostgreSQL bigint maximum inode because it is precision-unsafe in JS', async () => {
|
||||
it('preserves the PostgreSQL bigint maximum inode exactly', async () => {
|
||||
await touchFile('Book/book.epub');
|
||||
setupStatMock(9223372036854775807n);
|
||||
|
||||
const { candidates } = await findBookCandidates(root);
|
||||
|
||||
expect(candidates[0].files[0].ino).toBe(0);
|
||||
expect(candidates[0].files[0].ino).toBe(9223372036854775807n);
|
||||
});
|
||||
|
||||
it('does not emit a warning for normal inodes', async () => {
|
||||
@@ -181,31 +175,40 @@ describe('findBookCandidates — oversized MergerFS inode', () => {
|
||||
|
||||
expect(candidates).toHaveLength(2);
|
||||
for (const c of candidates) {
|
||||
expect(c.files[0].ino).toBe(0);
|
||||
expect(c.files[0].ino).toBe(OVERSIZED_INO);
|
||||
}
|
||||
});
|
||||
|
||||
it('sets ino=0 for the maximum possible unsigned 64-bit inode', async () => {
|
||||
it('preserves the maximum possible unsigned 64-bit inode', async () => {
|
||||
await touchFile('Book/book.epub');
|
||||
setupStatMock(18446744073709551615n);
|
||||
|
||||
const { candidates } = await findBookCandidates(root);
|
||||
|
||||
expect(candidates[0].files[0].ino).toBe(0);
|
||||
expect(candidates[0].files[0].ino).toBe(18446744073709551615n);
|
||||
});
|
||||
|
||||
it('preserves negative inode values reported by a mounted filesystem', async () => {
|
||||
await touchFile('Book/book.epub');
|
||||
setupStatMock(-8537732241968812306n);
|
||||
|
||||
const { candidates } = await findBookCandidates(root);
|
||||
|
||||
expect(candidates[0].files[0].ino).toBe(-8537732241968812306n);
|
||||
});
|
||||
});
|
||||
|
||||
// ── findLooseFileCandidates ───────────────────────────────────────────────────
|
||||
|
||||
describe('findLooseFileCandidates — oversized MergerFS inode', () => {
|
||||
it('sets ino=0 for oversized inodes in book_per_file mode', async () => {
|
||||
describe('findLooseFileCandidates - exact MergerFS inode', () => {
|
||||
it('preserves oversized inodes in book_per_file mode', async () => {
|
||||
await touchFile('book.epub');
|
||||
setupStatMock(OVERSIZED_INO);
|
||||
|
||||
const { candidates } = await findLooseFileCandidates(root);
|
||||
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect(candidates[0].files[0].ino).toBe(0);
|
||||
expect(candidates[0].files[0].ino).toBe(OVERSIZED_INO);
|
||||
});
|
||||
|
||||
it('passes normal inodes through in book_per_file mode', async () => {
|
||||
@@ -214,32 +217,31 @@ describe('findLooseFileCandidates — oversized MergerFS inode', () => {
|
||||
|
||||
const { candidates } = await findLooseFileCandidates(root);
|
||||
|
||||
expect(candidates[0].files[0].ino).toBe(Number(NORMAL_INO));
|
||||
expect(candidates[0].files[0].ino).toBe(NORMAL_INO);
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildSingleBookCandidate ──────────────────────────────────────────────────
|
||||
|
||||
describe('buildSingleBookCandidate — oversized MergerFS inode', () => {
|
||||
it('sets ino=0 when stat returns an inode exceeding PostgreSQL bigint range', async () => {
|
||||
describe('buildSingleBookCandidate - exact MergerFS inode', () => {
|
||||
it('preserves an inode exceeding the PostgreSQL bigint range', async () => {
|
||||
await touchFile('Book/book.epub');
|
||||
setupStatMock(OVERSIZED_INO);
|
||||
|
||||
const result = await buildSingleBookCandidate(join(root, 'Book'), root);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.files[0].ino).toBe(0);
|
||||
expect(result!.files[0].ino).toBe(OVERSIZED_INO);
|
||||
});
|
||||
|
||||
it('emits a logger warning for oversized inodes', async () => {
|
||||
it('does not warn for an exact unsigned 64-bit inode', async () => {
|
||||
await touchFile('Book/book.epub');
|
||||
setupStatMock(OVERSIZED_INO);
|
||||
|
||||
const warnings: string[] = [];
|
||||
await buildSingleBookCandidate(join(root, 'Book'), root, [], (msg) => warnings.push(msg));
|
||||
|
||||
expect(warnings.some((w) => w.includes(String(OVERSIZED_INO)))).toBe(true);
|
||||
expect(warnings.some((w) => w.includes('rename detection disabled'))).toBe(true);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('passes normal inodes through unchanged', async () => {
|
||||
@@ -248,7 +250,7 @@ describe('buildSingleBookCandidate — oversized MergerFS inode', () => {
|
||||
|
||||
const result = await buildSingleBookCandidate(join(root, 'Book'), root);
|
||||
|
||||
expect(result!.files[0].ino).toBe(Number(NORMAL_INO));
|
||||
expect(result!.files[0].ino).toBe(NORMAL_INO);
|
||||
});
|
||||
|
||||
it('still returns correct folderPath and file list with oversized inodes', async () => {
|
||||
@@ -261,6 +263,6 @@ describe('buildSingleBookCandidate — oversized MergerFS inode', () => {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.folderPath).toBe(join(root, 'Book'));
|
||||
expect(result!.files).toHaveLength(2);
|
||||
expect(result!.files.every((f) => f.ino === 0)).toBe(true);
|
||||
expect(result!.files.every((f) => f.ino === OVERSIZED_INO)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile, chmod } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
import { findBookCandidates, buildSingleBookCandidate, findLooseFileCandidates, clampIno, BookCandidate } from './walk';
|
||||
import { findBookCandidates, buildSingleBookCandidate, findLooseFileCandidates, BookCandidate } from './walk';
|
||||
|
||||
let suiteRoot: string;
|
||||
let root: string;
|
||||
@@ -737,11 +737,11 @@ describe('findLooseFileCandidates — FileStat fields', () => {
|
||||
expect(candidates[0].files[0].mtime).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('candidate file has a numeric inode', async () => {
|
||||
it('candidate file has a bigint inode', async () => {
|
||||
await file('book.epub');
|
||||
|
||||
const { candidates } = await findLooseFileCandidates(root);
|
||||
expect(typeof candidates[0].files[0].ino).toBe('number');
|
||||
expect(typeof candidates[0].files[0].ino).toBe('bigint');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -951,44 +951,3 @@ describe('incremental scan — findLooseFileCandidates', () => {
|
||||
expect(second.unchangedDirs.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── clampIno ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('clampIno', () => {
|
||||
it('passes through zero unchanged', () => {
|
||||
expect(clampIno(0n)).toBe(0);
|
||||
});
|
||||
|
||||
it('passes through typical small filesystem inodes', () => {
|
||||
expect(clampIno(1n)).toBe(1);
|
||||
expect(clampIno(12345n)).toBe(12345);
|
||||
expect(clampIno(1000000n)).toBe(1000000);
|
||||
});
|
||||
|
||||
it('passes through the maximum safe JS integer', () => {
|
||||
expect(clampIno(BigInt(Number.MAX_SAFE_INTEGER))).toBe(Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
|
||||
it('clamps values one above the maximum safe JS integer to 0', () => {
|
||||
expect(clampIno(BigInt(Number.MAX_SAFE_INTEGER) + 1n)).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps in-range 64-bit inodes that are precision-unsafe in JavaScript', () => {
|
||||
// Exact inode reported in issue #84 on Unraid shfs
|
||||
expect(clampIno(651896050678335552n)).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps the exact MergerFS inode from the bug report to 0', () => {
|
||||
// 17237992710316634000 is the unsigned 64-bit inode reported in the MergerFS issue
|
||||
expect(clampIno(17237992710316634000n)).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps the maximum unsigned 64-bit inode (2^64 - 1) to 0', () => {
|
||||
expect(clampIno(18446744073709551615n)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns a number type', () => {
|
||||
expect(typeof clampIno(1n)).toBe('number');
|
||||
expect(typeof clampIno(17237992710316634000n)).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,25 +3,10 @@ import { basename, dirname, join, relative } from 'path';
|
||||
|
||||
import { classifyFile, isPrimaryFormat, isAudioFormat, type FileRole } from './classify';
|
||||
|
||||
const JS_NUMBER_MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
|
||||
/**
|
||||
* Convert a raw filesystem inode (bigint from stat with { bigint: true }) to a number
|
||||
* that can be matched safely in JavaScript.
|
||||
*
|
||||
* Any inode above Number.MAX_SAFE_INTEGER cannot be represented exactly as a JS number,
|
||||
* so we clamp it to 0. Value 0 is the existing sentinel meaning "rename detection
|
||||
* disabled for this file".
|
||||
*/
|
||||
export function clampIno(ino: bigint): number {
|
||||
if (ino < 0n || ino > JS_NUMBER_MAX_SAFE_INTEGER) return 0;
|
||||
return Number(ino);
|
||||
}
|
||||
|
||||
export interface FileStat {
|
||||
absolutePath: string;
|
||||
relPath: string; // relative to library folder root
|
||||
ino: number;
|
||||
ino: bigint;
|
||||
sizeBytes: number;
|
||||
mtime: Date;
|
||||
format: string | null;
|
||||
@@ -110,11 +95,9 @@ async function statFilesIntoAcc(
|
||||
for (const entry of statResults) {
|
||||
if (!entry) continue;
|
||||
const { full, s } = entry;
|
||||
const ino = clampIno(s.ino);
|
||||
const ino = s.ino;
|
||||
if (s.ino === 0n) {
|
||||
logger?.(`File has inode 0 (likely network mount), rename detection disabled: ${full}`);
|
||||
} else if (ino === 0) {
|
||||
logger?.(`File inode cannot be represented safely for matching (inode ${s.ino}), rename detection disabled: ${full}`);
|
||||
}
|
||||
if (!acc.has(dir)) acc.set(dir, []);
|
||||
const { format, role } = classifyFile(full);
|
||||
@@ -455,14 +438,10 @@ export async function buildSingleBookCandidate(
|
||||
const s = await stat(full, { bigint: true }).catch(() => null);
|
||||
if (!s) return null;
|
||||
const { format, role } = classifyFile(full);
|
||||
const ino = clampIno(s.ino);
|
||||
if (s.ino !== 0n && ino === 0) {
|
||||
logger?.(`File inode cannot be represented safely for matching (inode ${s.ino}), rename detection disabled: ${full}`);
|
||||
}
|
||||
return {
|
||||
absolutePath: full,
|
||||
relPath: relative(libraryFolderPath, full),
|
||||
ino,
|
||||
ino: s.ino,
|
||||
sizeBytes: Number(s.size),
|
||||
mtime: s.mtime,
|
||||
format,
|
||||
|
||||
@@ -161,7 +161,7 @@ describe('ScannerRepository', () => {
|
||||
queues.select.push([{ id: 1, status: 'present', folderPath: '/books/A', primaryFileId: 90 }]);
|
||||
queues.select.push([{ bookId: 1, absolutePath: '/books/A/book.epub', format: 'epub' }]);
|
||||
queues.select.push([{ id: 7, absolutePath: '/books/A/book.epub' }]);
|
||||
queues.select.push([{ id: 90, bookId: 1, absolutePath: '/books/A/book.epub', ino: 999, sizeBytes: 10, mtime: new Date(), fileHash: 'x' }]);
|
||||
queues.select.push([{ id: 90, bookId: 1, absolutePath: '/books/A/book.epub', ino: 999n, sizeBytes: 10, mtime: new Date(), fileHash: 'x' }]);
|
||||
queues.select.push([{ id: 15, bookId: 1, libraryId: 4, primaryFileId: 90 }]);
|
||||
queues.select.push([{ id: 16, bookId: 2, libraryId: 5, primaryFileId: 91 }]);
|
||||
queues.select.push([{ id: 3, folderPath: '/books/A' }]);
|
||||
@@ -175,7 +175,7 @@ describe('ScannerRepository', () => {
|
||||
await expect(repo.findPrimaryBookFilesByLibrary(4)).resolves.toEqual([{ bookId: 1, absolutePath: '/books/A/book.epub', format: 'epub' }]);
|
||||
await expect(repo.findPrimaryBookFilesByBookId(1)).resolves.toEqual([{ id: 7, absolutePath: '/books/A/book.epub' }]);
|
||||
await expect(repo.findBookFilesByLibraryFolder(4)).resolves.toEqual([
|
||||
{ id: 90, bookId: 1, absolutePath: '/books/A/book.epub', ino: 999, sizeBytes: 10, mtime: expect.any(Date), fileHash: 'x' },
|
||||
{ id: 90, bookId: 1, absolutePath: '/books/A/book.epub', ino: 999n, sizeBytes: 10, mtime: expect.any(Date), fileHash: 'x' },
|
||||
]);
|
||||
await expect(repo.findBookFileByAbsolutePath('/books/A/book.epub')).resolves.toEqual({ id: 15, bookId: 1, libraryId: 4, primaryFileId: 90 });
|
||||
await expect(repo.findBookFileByAbsolutePath('/books/B/book.epub', 5)).resolves.toEqual({ id: 16, bookId: 2, libraryId: 5, primaryFileId: 91 });
|
||||
@@ -244,8 +244,8 @@ describe('ScannerRepository', () => {
|
||||
const { repo, queues, db } = makeRepo();
|
||||
queues.select.push([{ id: 1, bookId: 1 }]);
|
||||
queues.select.push([{ id: 2, bookId: 2 }]);
|
||||
queues.select.push([{ id: 10, file: { id: 10, ino: 123 } }]);
|
||||
queues.select.push([{ id: 11, file: { id: 11, ino: 124 } }]);
|
||||
queues.select.push([{ id: 10, file: { id: 10, ino: 123n } }]);
|
||||
queues.select.push([{ id: 11, file: { id: 11, ino: 124n } }]);
|
||||
queues.select.push([{ id: 12, file: { id: 12, fileHash: 'x' } }]);
|
||||
queues.select.push([{ id: 13, file: { id: 13, fileHash: 'y' } }]);
|
||||
queues.select.push([{ id: 14, file: { id: 14, fileHash: 'z' } }]);
|
||||
@@ -255,8 +255,8 @@ describe('ScannerRepository', () => {
|
||||
|
||||
await expect(repo.findBookFilesByBookId(1)).resolves.toEqual([{ id: 1, bookId: 1 }]);
|
||||
await expect(repo.findBookFilesByBookIds([1, 2])).resolves.toEqual([{ id: 2, bookId: 2 }]);
|
||||
await expect(repo.findBookFileWithContextByIno(123)).resolves.toEqual({ id: 10, file: { id: 10, ino: 123 } });
|
||||
await expect(repo.findBookFileWithContextByIno(124, 4)).resolves.toEqual({ id: 11, file: { id: 11, ino: 124 } });
|
||||
await expect(repo.findBookFileWithContextByIno(123)).resolves.toEqual({ id: 10, file: { id: 10, ino: 123n } });
|
||||
await expect(repo.findBookFileWithContextByIno(124, 4)).resolves.toEqual({ id: 11, file: { id: 11, ino: 124n } });
|
||||
await expect(repo.findMissingBookFileWithContextByIno(125)).resolves.toEqual({ id: 12, file: { id: 12, fileHash: 'x' } });
|
||||
await expect(repo.findBookFileWithContextByHash('abc')).resolves.toEqual({ id: 13, file: { id: 13, fileHash: 'y' } });
|
||||
await expect(repo.findMissingBookFileWithContextByHash('def')).resolves.toEqual({ id: 14, file: { id: 14, fileHash: 'z' } });
|
||||
|
||||
@@ -348,7 +348,7 @@ export class ScannerRepository {
|
||||
await this.db.delete(bookFiles).where(eq(bookFiles.id, id));
|
||||
}
|
||||
|
||||
async findBookFileWithContextByIno(ino: number, libraryId?: number) {
|
||||
async findBookFileWithContextByIno(ino: bigint, libraryId?: number) {
|
||||
const whereClause = libraryId == null ? eq(bookFiles.ino, ino) : and(eq(bookFiles.ino, ino), eq(books.libraryId, libraryId));
|
||||
const [row] = await this.db
|
||||
.select({
|
||||
@@ -366,7 +366,7 @@ export class ScannerRepository {
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async findMissingBookFileWithContextByIno(ino: number) {
|
||||
async findMissingBookFileWithContextByIno(ino: bigint) {
|
||||
const [row] = await this.db
|
||||
.select({
|
||||
file: bookFiles,
|
||||
|
||||
@@ -21,14 +21,13 @@ import { ScannerService } from './scanner.service';
|
||||
import { ScanJobStore } from './scan-job-store.service';
|
||||
import { DEFAULT_FORMAT_PRIORITY } from './lib/classify';
|
||||
import type { BookCandidate, FileStat } from './lib/walk';
|
||||
import { findBookCandidates, findLooseFileCandidates, buildSingleBookCandidate, clampIno } from './lib/walk';
|
||||
import { findBookCandidates, findLooseFileCandidates, buildSingleBookCandidate } from './lib/walk';
|
||||
import { computeFileHash } from './lib/hash';
|
||||
import * as assembleBookCardsModule from '../book/utils/assemble-book-cards';
|
||||
|
||||
const mockFindCandidates = findBookCandidates as MockedFunction<typeof findBookCandidates>;
|
||||
const mockFindLooseCandidates = findLooseFileCandidates as MockedFunction<typeof findLooseFileCandidates>;
|
||||
const mockBuildSingleCandidate = buildSingleBookCandidate as MockedFunction<typeof buildSingleBookCandidate>;
|
||||
const mockClampIno = clampIno as MockedFunction<typeof clampIno>;
|
||||
const mockFingerprint = computeFileHash as MockedFunction<typeof computeFileHash>;
|
||||
const mockReaddir = readdir as MockedFunction<typeof readdir>;
|
||||
const mockStat = stat as MockedFunction<typeof stat>;
|
||||
@@ -39,7 +38,7 @@ function makeFileStat(overrides: Partial<FileStat> = {}): FileStat {
|
||||
return {
|
||||
absolutePath: '/library/Author/Book/book.epub',
|
||||
relPath: 'Author/Book/book.epub',
|
||||
ino: 1001,
|
||||
ino: 1001n,
|
||||
sizeBytes: 1024,
|
||||
mtime: new Date('2024-01-01'),
|
||||
...overrides,
|
||||
@@ -57,7 +56,7 @@ function makeBookFile(overrides: Record<string, unknown> = {}) {
|
||||
libraryFolderId: 1,
|
||||
absolutePath: '/library/Author/Book/book.epub',
|
||||
relPath: 'Author/Book/book.epub',
|
||||
ino: 1001,
|
||||
ino: 1001n,
|
||||
sizeBytes: 1024,
|
||||
mtime: new Date('2024-01-01'),
|
||||
fileHash: 'abc123',
|
||||
@@ -190,9 +189,6 @@ beforeEach(() => {
|
||||
mockFindCandidates.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);
|
||||
mockClampIno.mockImplementation((ino: bigint) => {
|
||||
return ino > BigInt(Number.MAX_SAFE_INTEGER) ? 0 : Number(ino);
|
||||
});
|
||||
mockFingerprint.mockResolvedValue('hash-abc');
|
||||
mockReaddir.mockResolvedValue([]);
|
||||
mockStat.mockResolvedValue({ isFile: () => true, ino: 2001n, size: 1024, mtime: new Date('2024-01-01') } as any);
|
||||
@@ -648,7 +644,7 @@ describe('genuinely new primary file', () => {
|
||||
const opf = makeFileStat({
|
||||
absolutePath: '/library/Book/metadata.opf',
|
||||
relPath: 'Book/metadata.opf',
|
||||
ino: 1002,
|
||||
ino: 1002n,
|
||||
format: 'opf',
|
||||
role: 'metadata',
|
||||
});
|
||||
@@ -678,7 +674,7 @@ describe('genuinely new primary file', () => {
|
||||
const opf = makeFileStat({
|
||||
absolutePath: '/library/Book/metadata.opf',
|
||||
relPath: 'Book/metadata.opf',
|
||||
ino: 1002,
|
||||
ino: 1002n,
|
||||
format: 'opf',
|
||||
role: 'metadata',
|
||||
});
|
||||
@@ -702,7 +698,7 @@ describe('genuinely new primary file', () => {
|
||||
const opf = makeFileStat({
|
||||
absolutePath: '/library/Book/metadata.opf',
|
||||
relPath: 'Book/metadata.opf',
|
||||
ino: 1002,
|
||||
ino: 1002n,
|
||||
format: 'opf',
|
||||
role: 'metadata',
|
||||
});
|
||||
@@ -735,7 +731,7 @@ describe('genuinely new primary file', () => {
|
||||
const opf = makeFileStat({
|
||||
absolutePath: '/library/Book/metadata.opf',
|
||||
relPath: 'Book/metadata.opf',
|
||||
ino: 1002,
|
||||
ino: 1002n,
|
||||
format: 'opf',
|
||||
role: 'metadata',
|
||||
});
|
||||
@@ -767,7 +763,7 @@ describe('genuinely new primary file', () => {
|
||||
const opf = makeFileStat({
|
||||
absolutePath: '/library/Book/metadata.opf',
|
||||
relPath: 'Book/metadata.opf',
|
||||
ino: 1002,
|
||||
ino: 1002n,
|
||||
sizeBytes: 128,
|
||||
mtime: new Date('2024-01-02'),
|
||||
format: 'opf',
|
||||
@@ -925,11 +921,12 @@ describe('file identity resolution', () => {
|
||||
expect(repo.createBookFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates path when inode matches a known file at a different path (renamed file)', async () => {
|
||||
const fileStat = makeFileStat({ absolutePath: '/library/Author/Book/renamed.epub', relPath: 'Author/Book/renamed.epub', ino: 9999 });
|
||||
|
||||
it('repairs a previously clamped inode even when the file size and mtime are unchanged', async () => {
|
||||
const mtime = new Date('2024-01-01');
|
||||
const exactIno = 14351917807348929000n;
|
||||
const fileStat = makeFileStat({ ino: exactIno, mtime });
|
||||
const repo = makeRepo({
|
||||
findBookFilesByLibraryFolder: vi.fn().mockResolvedValue([makeBookFile({ absolutePath: '/library/Author/Book/old-name.epub', ino: 9999 })]),
|
||||
findBookFilesByLibraryFolder: vi.fn().mockResolvedValue([makeBookFile({ ino: 0n, mtime, sizeBytes: fileStat.sizeBytes })]),
|
||||
findBooksByLibraryFolder: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 1, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Author/Book', status: 'present' }]),
|
||||
@@ -946,22 +943,48 @@ describe('file identity resolution', () => {
|
||||
await service.startScan(1, 'manual');
|
||||
await done;
|
||||
|
||||
expect(repo.updateBookFile).toHaveBeenCalledWith(1, expect.objectContaining({ ino: exactIno }));
|
||||
expect(repo.createBookFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates path when inode matches a known file at a different path (renamed file)', async () => {
|
||||
const fileStat = makeFileStat({ absolutePath: '/library/Author/Book/renamed.epub', relPath: 'Author/Book/renamed.epub', ino: 9999n });
|
||||
|
||||
const repo = makeRepo({
|
||||
findBookFilesByLibraryFolder: vi.fn().mockResolvedValue([makeBookFile({ absolutePath: '/library/Author/Book/old-name.epub', ino: 9999n })]),
|
||||
findBooksByLibraryFolder: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 1, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Author/Book', status: 'present' }]),
|
||||
});
|
||||
mockFindCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/Author/Book', [fileStat])],
|
||||
skippedDirs: new Set(),
|
||||
unchangedDirs: new Set(),
|
||||
dirMtimes: new Map(),
|
||||
});
|
||||
mockStat.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||
|
||||
const done = awaitScan(repo);
|
||||
const { service } = makeService(repo);
|
||||
await service.startScan(1, 'manual');
|
||||
await done;
|
||||
|
||||
expect(repo.updateBookFile).toHaveBeenCalledWith(1, expect.objectContaining({ absolutePath: '/library/Author/Book/renamed.epub' }));
|
||||
expect(repo.createBookFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reuses existing book id when an entire book folder is renamed via 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({
|
||||
absolutePath: '/library/Author/NewName/book.epub',
|
||||
relPath: 'Author/NewName/book.epub',
|
||||
ino: 9090,
|
||||
ino: 14351917807348929000n,
|
||||
});
|
||||
const existingFile = makeBookFile({
|
||||
id: 21,
|
||||
bookId: 10,
|
||||
absolutePath: '/library/Author/OldName/book.epub',
|
||||
relPath: 'Author/OldName/book.epub',
|
||||
ino: 9090,
|
||||
ino: 14351917807348929000n,
|
||||
fileHash: 'rename-hash',
|
||||
});
|
||||
|
||||
@@ -977,6 +1000,7 @@ describe('file identity resolution', () => {
|
||||
unchangedDirs: new Set(),
|
||||
dirMtimes: new Map(),
|
||||
});
|
||||
mockStat.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||
|
||||
const done = awaitScan(repo);
|
||||
const { service } = makeService(repo);
|
||||
@@ -1000,14 +1024,14 @@ describe('file identity resolution', () => {
|
||||
const renamedFile = makeFileStat({
|
||||
absolutePath: '/library/Author/NewName/book.epub',
|
||||
relPath: 'Author/NewName/book.epub',
|
||||
ino: 0,
|
||||
ino: 0n,
|
||||
});
|
||||
const existingFile = makeBookFile({
|
||||
id: 31,
|
||||
bookId: 11,
|
||||
absolutePath: '/library/Author/OldName/book.epub',
|
||||
relPath: 'Author/OldName/book.epub',
|
||||
ino: 0,
|
||||
ino: 0n,
|
||||
fileHash: 'rename-hash',
|
||||
});
|
||||
|
||||
@@ -1025,6 +1049,7 @@ describe('file identity resolution', () => {
|
||||
dirMtimes: new Map(),
|
||||
});
|
||||
mockFingerprint.mockResolvedValue('rename-hash');
|
||||
mockStat.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||
|
||||
const done = awaitScan(repo);
|
||||
const { service } = makeService(repo);
|
||||
@@ -1044,8 +1069,50 @@ describe('file identity resolution', () => {
|
||||
expect(repo.markBooksAsMissing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reuse a book when a hash matches but the original folder still exists', async () => {
|
||||
const newFile = makeFileStat({
|
||||
absolutePath: '/library/Author/NewBook/book.epub',
|
||||
relPath: 'Author/NewBook/book.epub',
|
||||
ino: 14351917807348929000n,
|
||||
});
|
||||
const existingFile = makeBookFile({
|
||||
id: 41,
|
||||
bookId: 12,
|
||||
absolutePath: '/library/Author/ExistingBook/book.epub',
|
||||
relPath: 'Author/ExistingBook/book.epub',
|
||||
ino: 15101917807348929000n,
|
||||
fileHash: 'shared-partial-hash',
|
||||
});
|
||||
const repo = makeRepo({
|
||||
findBooksByLibraryFolder: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 12, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Author/ExistingBook', status: 'present' }]),
|
||||
findBookFilesByLibraryFolder: vi.fn().mockResolvedValue([existingFile]),
|
||||
findBookFileByHash: vi.fn().mockResolvedValue(existingFile),
|
||||
});
|
||||
mockFindCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/Author/NewBook', [newFile])],
|
||||
skippedDirs: new Set(),
|
||||
unchangedDirs: new Set(),
|
||||
dirMtimes: new Map(),
|
||||
});
|
||||
mockFingerprint.mockResolvedValue('shared-partial-hash');
|
||||
mockStat.mockResolvedValue({ isFile: () => true } as any);
|
||||
|
||||
const done = awaitScan(repo);
|
||||
const { service } = makeService(repo);
|
||||
await service.startScan(1, 'manual');
|
||||
await done;
|
||||
|
||||
expect(repo.updateBookFolderPath).not.toHaveBeenCalled();
|
||||
expect(repo.createBook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ libraryId: 1, libraryFolderId: 1, folderPath: '/library/Author/NewBook' }),
|
||||
);
|
||||
expect(repo.createBookFile).toHaveBeenCalledWith(expect.objectContaining({ absolutePath: newFile.absolutePath, ino: newFile.ino }));
|
||||
});
|
||||
|
||||
it('gracefully skips a file that disappears during fingerprinting (ENOENT) — scan still completes', async () => {
|
||||
const fileStat = makeFileStat({ ino: 7777 }); // different ino so inode match fails
|
||||
const fileStat = makeFileStat({ ino: 7777n }); // different ino so inode match fails
|
||||
mockFindCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/Author/Book', [fileStat])],
|
||||
skippedDirs: new Set(),
|
||||
@@ -1066,8 +1133,8 @@ describe('file identity resolution', () => {
|
||||
});
|
||||
|
||||
it('updates path when hash matches a known file from a different filesystem (cross-fs move)', async () => {
|
||||
const fileStat = makeFileStat({ absolutePath: '/library/moved.epub', relPath: 'moved.epub', ino: 8888 });
|
||||
const existingFile = makeBookFile({ absolutePath: '/old-library/book.epub', ino: 1111, fileHash: 'fixed-hash' });
|
||||
const fileStat = makeFileStat({ absolutePath: '/library/moved.epub', relPath: 'moved.epub', ino: 8888n });
|
||||
const existingFile = makeBookFile({ absolutePath: '/old-library/book.epub', ino: 1111n, fileHash: 'fixed-hash' });
|
||||
|
||||
const repo = makeRepo({
|
||||
findBookFilesByLibraryFolder: vi.fn().mockResolvedValue([existingFile]),
|
||||
@@ -1083,7 +1150,7 @@ describe('file identity resolution', () => {
|
||||
dirMtimes: new Map(),
|
||||
});
|
||||
mockFingerprint.mockResolvedValue('fixed-hash');
|
||||
|
||||
mockStat.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||
const done = awaitScan(repo);
|
||||
const { service } = makeService(repo);
|
||||
await service.startScan(1, 'manual');
|
||||
@@ -1158,7 +1225,7 @@ describe('allowedFormats filtering', () => {
|
||||
describe('audio multi-file audiobook', () => {
|
||||
it('calls extractAndSave on the winner (first natural-sorted) audio file only', async () => {
|
||||
const file1 = makeFileStat({ absolutePath: '/library/Book/chapter-01.mp3', relPath: 'Book/chapter-01.mp3' });
|
||||
const file2 = makeFileStat({ absolutePath: '/library/Book/chapter-02.mp3', relPath: 'Book/chapter-02.mp3', ino: 1002 });
|
||||
const file2 = makeFileStat({ absolutePath: '/library/Book/chapter-02.mp3', relPath: 'Book/chapter-02.mp3', ino: 1002n });
|
||||
const candidate = makeCandidate('/library/Book', [file1, file2]);
|
||||
mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
|
||||
|
||||
@@ -1174,8 +1241,8 @@ describe('audio multi-file audiobook', () => {
|
||||
|
||||
it('calls extractAudioFileDuration for ALL new audio files including the winner', async () => {
|
||||
const file1 = makeFileStat({ absolutePath: '/library/Book/chapter-01.mp3', relPath: 'Book/chapter-01.mp3' });
|
||||
const file2 = makeFileStat({ absolutePath: '/library/Book/chapter-02.mp3', relPath: 'Book/chapter-02.mp3', ino: 1002 });
|
||||
const file3 = makeFileStat({ absolutePath: '/library/Book/chapter-03.mp3', relPath: 'Book/chapter-03.mp3', ino: 1003 });
|
||||
const file2 = makeFileStat({ absolutePath: '/library/Book/chapter-02.mp3', relPath: 'Book/chapter-02.mp3', ino: 1002n });
|
||||
const file3 = makeFileStat({ absolutePath: '/library/Book/chapter-03.mp3', relPath: 'Book/chapter-03.mp3', ino: 1003n });
|
||||
const candidate = makeCandidate('/library/Book', [file1, file2, file3]);
|
||||
mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
|
||||
|
||||
@@ -1195,7 +1262,7 @@ describe('audio multi-file audiobook', () => {
|
||||
|
||||
it('calls aggregateAudioDuration after processing audio files', async () => {
|
||||
const file1 = makeFileStat({ absolutePath: '/library/Book/chapter-01.mp3', relPath: 'Book/chapter-01.mp3' });
|
||||
const file2 = makeFileStat({ absolutePath: '/library/Book/chapter-02.mp3', relPath: 'Book/chapter-02.mp3', ino: 1002 });
|
||||
const file2 = makeFileStat({ absolutePath: '/library/Book/chapter-02.mp3', relPath: 'Book/chapter-02.mp3', ino: 1002n });
|
||||
const candidate = makeCandidate('/library/Book', [file1, file2]);
|
||||
mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
|
||||
|
||||
@@ -1239,9 +1306,9 @@ describe('audio multi-file audiobook', () => {
|
||||
});
|
||||
|
||||
it('uses epub metadata (not audio) when a book has both epub and mp3 files', async () => {
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 2001 });
|
||||
const mp3a = makeFileStat({ absolutePath: '/library/Book/book/01.mp3', relPath: 'Book/book/01.mp3', ino: 2002 });
|
||||
const mp3b = makeFileStat({ absolutePath: '/library/Book/book/02.mp3', relPath: 'Book/book/02.mp3', ino: 2003 });
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 2001n });
|
||||
const mp3a = makeFileStat({ absolutePath: '/library/Book/book/01.mp3', relPath: 'Book/book/01.mp3', ino: 2002n });
|
||||
const mp3b = makeFileStat({ absolutePath: '/library/Book/book/02.mp3', relPath: 'Book/book/02.mp3', ino: 2003n });
|
||||
const candidate = makeCandidate('/library/Book', [epub, mp3a, mp3b]);
|
||||
mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
|
||||
|
||||
@@ -1257,9 +1324,9 @@ describe('audio multi-file audiobook', () => {
|
||||
});
|
||||
|
||||
it('extracts duration from ALL new audio files when ebook is the winner', async () => {
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 2001 });
|
||||
const mp3a = makeFileStat({ absolutePath: '/library/Book/book/01.mp3', relPath: 'Book/book/01.mp3', ino: 2002 });
|
||||
const mp3b = makeFileStat({ absolutePath: '/library/Book/book/02.mp3', relPath: 'Book/book/02.mp3', ino: 2003 });
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 2001n });
|
||||
const mp3a = makeFileStat({ absolutePath: '/library/Book/book/01.mp3', relPath: 'Book/book/01.mp3', ino: 2002n });
|
||||
const mp3b = makeFileStat({ absolutePath: '/library/Book/book/02.mp3', relPath: 'Book/book/02.mp3', ino: 2003n });
|
||||
const candidate = makeCandidate('/library/Book', [epub, mp3a, mp3b]);
|
||||
mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
|
||||
|
||||
@@ -1277,7 +1344,7 @@ describe('audio multi-file audiobook', () => {
|
||||
|
||||
it('falls back to audio metadata when no non-audio metadata format is in the candidate', async () => {
|
||||
const mp3a = makeFileStat({ absolutePath: '/library/Book/01.mp3', relPath: 'Book/01.mp3' });
|
||||
const mp3b = makeFileStat({ absolutePath: '/library/Book/02.mp3', relPath: 'Book/02.mp3', ino: 1002 });
|
||||
const mp3b = makeFileStat({ absolutePath: '/library/Book/02.mp3', relPath: 'Book/02.mp3', ino: 1002n });
|
||||
const candidate = makeCandidate('/library/Book', [mp3a, mp3b]);
|
||||
mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
|
||||
|
||||
@@ -1314,8 +1381,8 @@ describe('multi-format metadata source routing', () => {
|
||||
organizationMode: 'book_per_folder',
|
||||
}),
|
||||
});
|
||||
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', ino: 3001 });
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 3002 });
|
||||
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', ino: 3001n });
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 3002n });
|
||||
mockFindCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/Book', [m4b, epub])],
|
||||
skippedDirs: new Set(),
|
||||
@@ -1343,8 +1410,8 @@ describe('multi-format metadata source routing', () => {
|
||||
organizationMode: 'book_per_folder',
|
||||
}),
|
||||
});
|
||||
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', ino: 3001 });
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 3002 });
|
||||
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', ino: 3001n });
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 3002n });
|
||||
mockFindCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/Book', [m4b, epub])],
|
||||
skippedDirs: new Set(),
|
||||
@@ -1363,8 +1430,8 @@ describe('multi-format metadata source routing', () => {
|
||||
|
||||
it('epub primary + m4b secondary: epub owns text/cover, m4b provides chapters/narrators', async () => {
|
||||
// Default formatPriority has epub before m4b — epub wins.
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 4001 });
|
||||
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', ino: 4002 });
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 4001n });
|
||||
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', ino: 4002n });
|
||||
mockFindCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/Book', [epub, m4b])],
|
||||
skippedDirs: new Set(),
|
||||
@@ -1390,9 +1457,9 @@ describe('multi-format metadata source routing', () => {
|
||||
});
|
||||
|
||||
it('epub primary + multi-track m4b: chapters/narrators from first m4b, duration from all m4b files', async () => {
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 5001 });
|
||||
const m4b1 = makeFileStat({ absolutePath: '/library/Book/disc-1.m4b', relPath: 'Book/disc-1.m4b', ino: 5002 });
|
||||
const m4b2 = makeFileStat({ absolutePath: '/library/Book/disc-2.m4b', relPath: 'Book/disc-2.m4b', ino: 5003 });
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 5001n });
|
||||
const m4b1 = makeFileStat({ absolutePath: '/library/Book/disc-1.m4b', relPath: 'Book/disc-1.m4b', ino: 5002n });
|
||||
const m4b2 = makeFileStat({ absolutePath: '/library/Book/disc-2.m4b', relPath: 'Book/disc-2.m4b', ino: 5003n });
|
||||
mockFindCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/Book', [epub, m4b1, m4b2])],
|
||||
skippedDirs: new Set(),
|
||||
@@ -1421,9 +1488,9 @@ describe('multi-format metadata source routing', () => {
|
||||
|
||||
it('epub + pdf + mobi all new: only epub metadata extracted, pdf and mobi are ignored', async () => {
|
||||
// epub comes first in DEFAULT_FORMAT_PRIORITY
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 6001 });
|
||||
const pdf = makeFileStat({ absolutePath: '/library/Book/book.pdf', relPath: 'Book/book.pdf', ino: 6002 });
|
||||
const mobi = makeFileStat({ absolutePath: '/library/Book/book.mobi', relPath: 'Book/book.mobi', ino: 6003 });
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 6001n });
|
||||
const pdf = makeFileStat({ absolutePath: '/library/Book/book.pdf', relPath: 'Book/book.pdf', ino: 6002n });
|
||||
const mobi = makeFileStat({ absolutePath: '/library/Book/book.mobi', relPath: 'Book/book.mobi', ino: 6003n });
|
||||
mockFindCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/Book', [epub, pdf, mobi])],
|
||||
skippedDirs: new Set(),
|
||||
@@ -1450,8 +1517,8 @@ describe('multi-format metadata source routing', () => {
|
||||
organizationMode: 'book_per_folder',
|
||||
}),
|
||||
});
|
||||
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', ino: 7001 });
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 7002 });
|
||||
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', ino: 7001n });
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 7002n });
|
||||
mockFindCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/Book', [m4b, epub])],
|
||||
skippedDirs: new Set(),
|
||||
@@ -1476,8 +1543,8 @@ describe('multi-format metadata source routing', () => {
|
||||
|
||||
describe('incremental scan — no re-extraction on unchanged winner', () => {
|
||||
it('does not call extractAndSave when winner m4b was already scanned and a new epub is added', async () => {
|
||||
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', ino: 8001 });
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 8002 });
|
||||
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', ino: 8001n });
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 8002n });
|
||||
|
||||
const repo = makeRepo({
|
||||
findLibrarySettings: vi.fn().mockResolvedValue({
|
||||
@@ -1513,8 +1580,8 @@ describe('incremental scan — no re-extraction on unchanged winner', () => {
|
||||
});
|
||||
|
||||
it('extracts chapters/narrators/duration from new m4b even when winner epub was already scanned', async () => {
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 9001 });
|
||||
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', ino: 9002 });
|
||||
const epub = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', ino: 9001n });
|
||||
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', ino: 9002n });
|
||||
|
||||
const repo = makeRepo({
|
||||
// epub already exists in DB — not new; m4b is genuinely new
|
||||
@@ -1544,7 +1611,7 @@ describe('incremental scan — no re-extraction on unchanged winner', () => {
|
||||
});
|
||||
|
||||
it('does not call aggregateAudioDuration when existing audio book has no new files', async () => {
|
||||
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', ino: 10001 });
|
||||
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', ino: 10001n });
|
||||
|
||||
const repo = makeRepo({
|
||||
findBooksByLibraryFolder: vi
|
||||
@@ -1621,7 +1688,7 @@ describe('cross-library transfer', () => {
|
||||
const destinationFile = makeFileStat({
|
||||
absolutePath: '/dest/Inbox/book.epub',
|
||||
relPath: 'Inbox/book.epub',
|
||||
ino: 4242,
|
||||
ino: 4242n,
|
||||
});
|
||||
const sourceFile = makeBookFile({
|
||||
id: 500,
|
||||
@@ -1629,7 +1696,7 @@ describe('cross-library transfer', () => {
|
||||
libraryFolderId: 10,
|
||||
absolutePath: '/source/Book/book.epub',
|
||||
relPath: 'Book/book.epub',
|
||||
ino: 4242,
|
||||
ino: 4242n,
|
||||
fileHash: 'transfer-hash',
|
||||
});
|
||||
|
||||
@@ -1658,6 +1725,7 @@ describe('cross-library transfer', () => {
|
||||
unchangedDirs: new Set(),
|
||||
dirMtimes: new Map(),
|
||||
});
|
||||
mockStat.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||
|
||||
const done = awaitScan(repo);
|
||||
const { service, notificationService } = makeService(repo);
|
||||
@@ -1686,7 +1754,7 @@ describe('cross-library transfer', () => {
|
||||
const destinationFile = makeFileStat({
|
||||
absolutePath: '/dest/Inbox/book.epub',
|
||||
relPath: 'Inbox/book.epub',
|
||||
ino: 4343,
|
||||
ino: 4343n,
|
||||
});
|
||||
const sourceFile = makeBookFile({
|
||||
id: 510,
|
||||
@@ -1694,7 +1762,7 @@ describe('cross-library transfer', () => {
|
||||
libraryFolderId: 10,
|
||||
absolutePath: '/source/Book/book.epub',
|
||||
relPath: 'Book/book.epub',
|
||||
ino: 4343,
|
||||
ino: 4343n,
|
||||
fileHash: 'transfer-hash',
|
||||
});
|
||||
|
||||
@@ -1719,7 +1787,7 @@ describe('cross-library transfer', () => {
|
||||
unchangedDirs: new Set(),
|
||||
dirMtimes: new Map(),
|
||||
});
|
||||
mockStat.mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
mockStat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
const done = awaitScan(repo);
|
||||
const { service } = makeService(repo);
|
||||
@@ -1745,7 +1813,7 @@ describe('cross-library transfer', () => {
|
||||
const destinationFile = makeFileStat({
|
||||
absolutePath: '/dest/Inbox/book.epub',
|
||||
relPath: 'Inbox/book.epub',
|
||||
ino: 9999,
|
||||
ino: 9999n,
|
||||
});
|
||||
const sourceFile = makeBookFile({
|
||||
id: 600,
|
||||
@@ -1753,7 +1821,7 @@ describe('cross-library transfer', () => {
|
||||
libraryFolderId: 11,
|
||||
absolutePath: '/source/Book/book.epub',
|
||||
relPath: 'Book/book.epub',
|
||||
ino: 2222,
|
||||
ino: 2222n,
|
||||
fileHash: 'transfer-hash',
|
||||
});
|
||||
|
||||
@@ -1786,6 +1854,7 @@ describe('cross-library transfer', () => {
|
||||
dirMtimes: new Map(),
|
||||
});
|
||||
mockFingerprint.mockResolvedValue('transfer-hash');
|
||||
mockStat.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||
|
||||
const done = awaitScan(repo);
|
||||
const { service } = makeService(repo);
|
||||
@@ -1810,7 +1879,7 @@ describe('cross-library transfer', () => {
|
||||
const destinationFile = makeFileStat({
|
||||
absolutePath: '/dest/Inbox/book.epub',
|
||||
relPath: 'Inbox/book.epub',
|
||||
ino: 4242,
|
||||
ino: 4242n,
|
||||
});
|
||||
const sourceFile = makeBookFile({
|
||||
id: 500,
|
||||
@@ -1818,7 +1887,7 @@ describe('cross-library transfer', () => {
|
||||
libraryFolderId: 10,
|
||||
absolutePath: '/source/Book/book.epub',
|
||||
relPath: 'Book/book.epub',
|
||||
ino: 4242,
|
||||
ino: 4242n,
|
||||
fileHash: 'transfer-hash',
|
||||
});
|
||||
|
||||
@@ -1849,6 +1918,7 @@ describe('cross-library transfer', () => {
|
||||
unchangedDirs: new Set(),
|
||||
dirMtimes: new Map(),
|
||||
});
|
||||
mockStat.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||
|
||||
const done = awaitScan(repo);
|
||||
const { service } = makeService(repo);
|
||||
@@ -1863,7 +1933,7 @@ describe('cross-library transfer', () => {
|
||||
const destinationFile = makeFileStat({
|
||||
absolutePath: '/dest/Inbox/book.epub',
|
||||
relPath: 'Inbox/book.epub',
|
||||
ino: 5151,
|
||||
ino: 5151n,
|
||||
});
|
||||
const sourceFile = makeBookFile({
|
||||
id: 700,
|
||||
@@ -1871,7 +1941,7 @@ describe('cross-library transfer', () => {
|
||||
libraryFolderId: 12,
|
||||
absolutePath: '/source/Book/book.epub',
|
||||
relPath: 'Book/book.epub',
|
||||
ino: 5151,
|
||||
ino: 5151n,
|
||||
fileHash: 'transfer-hash',
|
||||
});
|
||||
const repo = makeRepo({
|
||||
@@ -1921,12 +1991,12 @@ describe('virtual sibling drain', () => {
|
||||
const parentFile = makeFileStat({
|
||||
absolutePath: `${parentFolder}/Rising Queen.epub`,
|
||||
relPath: 'Rising Queen (Court of the Sea Fae Trilogy Book 3) (2020)/Rising Queen.epub',
|
||||
ino: 1001,
|
||||
ino: 1001n,
|
||||
});
|
||||
const childFile = makeFileStat({
|
||||
absolutePath: `${childFolder}/City of Thorns - C.N. Crawford.epub`,
|
||||
relPath: 'Rising Queen (Court of the Sea Fae Trilogy Book 3) (2020)/City of Thorns (2021)/City of Thorns - C.N. Crawford.epub',
|
||||
ino: 1002,
|
||||
ino: 1002n,
|
||||
});
|
||||
|
||||
const repo = makeRepo({
|
||||
@@ -1961,12 +2031,12 @@ describe('virtual sibling drain', () => {
|
||||
const parentFile = makeFileStat({
|
||||
absolutePath: `${parentFolder}/Rising Queen.epub`,
|
||||
relPath: 'Rising Queen (Court of the Sea Fae Trilogy Book 3) (2020)/Rising Queen.epub',
|
||||
ino: 1001,
|
||||
ino: 1001n,
|
||||
});
|
||||
const childFile = makeFileStat({
|
||||
absolutePath: `${childFolder}/City of Thorns - C.N. Crawford.epub`,
|
||||
relPath: 'Rising Queen (Court of the Sea Fae Trilogy Book 3) (2020)/City of Thorns (2021)/City of Thorns - C.N. Crawford.epub',
|
||||
ino: 1002,
|
||||
ino: 1002n,
|
||||
});
|
||||
|
||||
const repo = makeRepo({
|
||||
@@ -2052,8 +2122,8 @@ describe('virtual sibling drain', () => {
|
||||
});
|
||||
|
||||
it('uses inode ownership to merge renamed virtual children into the lowest-id survivor', async () => {
|
||||
const currentFirst = makeFileStat({ absolutePath: '/library/Series/renamed-one.epub', relPath: 'Series/renamed-one.epub', ino: 3003 });
|
||||
const currentSecond = makeFileStat({ absolutePath: '/library/Series/renamed-two.epub', relPath: 'Series/renamed-two.epub', ino: 1001 });
|
||||
const currentFirst = makeFileStat({ absolutePath: '/library/Series/renamed-one.epub', relPath: 'Series/renamed-one.epub', ino: 3003n });
|
||||
const currentSecond = makeFileStat({ absolutePath: '/library/Series/renamed-two.epub', relPath: 'Series/renamed-two.epub', ino: 1001n });
|
||||
const repo = makeRepo({
|
||||
findBooksByLibraryFolder: vi.fn().mockResolvedValue([
|
||||
{ id: 3, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Series/TitleOne', status: 'present' },
|
||||
@@ -2065,14 +2135,14 @@ describe('virtual sibling drain', () => {
|
||||
bookId: 3,
|
||||
absolutePath: '/library/Series/TitleOne/old-one.epub',
|
||||
relPath: 'Series/TitleOne/old-one.epub',
|
||||
ino: 3003,
|
||||
ino: 3003n,
|
||||
}),
|
||||
makeBookFile({
|
||||
id: 10,
|
||||
bookId: 1,
|
||||
absolutePath: '/library/Series/TitleTwo/old-two.epub',
|
||||
relPath: 'Series/TitleTwo/old-two.epub',
|
||||
ino: 1001,
|
||||
ino: 1001n,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
@@ -2127,7 +2197,7 @@ describe('virtual sibling drain', () => {
|
||||
|
||||
describe('reassigned file metadata extraction', () => {
|
||||
it('extracts metadata when a content file moves to a new book (path match, different bookId)', async () => {
|
||||
const fileStat = makeFileStat({ absolutePath: '/library/Author/Book/book.epub', relPath: 'Author/Book/book.epub', ino: 1001 });
|
||||
const fileStat = makeFileStat({ absolutePath: '/library/Author/Book/book.epub', relPath: 'Author/Book/book.epub', ino: 1001n });
|
||||
const repo = makeRepo({
|
||||
findBookFilesByLibraryFolder: vi
|
||||
.fn()
|
||||
@@ -2149,11 +2219,11 @@ describe('reassigned file metadata extraction', () => {
|
||||
});
|
||||
|
||||
it('extracts metadata when a content file is reassigned via inode match (different bookId)', async () => {
|
||||
const fileStat = makeFileStat({ absolutePath: '/library/Author/Book/book.epub', relPath: 'Author/Book/book.epub', ino: 7777 });
|
||||
const fileStat = makeFileStat({ absolutePath: '/library/Author/Book/book.epub', relPath: 'Author/Book/book.epub', ino: 7777n });
|
||||
const repo = makeRepo({
|
||||
findBookFilesByLibraryFolder: vi
|
||||
.fn()
|
||||
.mockResolvedValue([makeBookFile({ id: 5, bookId: 999, absolutePath: '/library/OldBook/book.epub', ino: 7777 })]),
|
||||
.mockResolvedValue([makeBookFile({ id: 5, bookId: 999, absolutePath: '/library/OldBook/book.epub', ino: 7777n })]),
|
||||
});
|
||||
mockFindCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/Author/Book', [fileStat])],
|
||||
@@ -2171,7 +2241,7 @@ describe('reassigned file metadata extraction', () => {
|
||||
});
|
||||
|
||||
it('does not extract metadata when a sidecar/cover file is reassigned', async () => {
|
||||
const fileStat = makeFileStat({ absolutePath: '/library/Author/Book/cover.jpg', relPath: 'Author/Book/cover.jpg', ino: 2002 });
|
||||
const fileStat = makeFileStat({ absolutePath: '/library/Author/Book/cover.jpg', relPath: 'Author/Book/cover.jpg', ino: 2002n });
|
||||
const repo = makeRepo({
|
||||
findBookFilesByLibraryFolder: vi
|
||||
.fn()
|
||||
@@ -2198,7 +2268,7 @@ describe('reassigned file metadata extraction', () => {
|
||||
const fileStat = makeFileStat({
|
||||
absolutePath: '/library/Author/Book/new-title.epub',
|
||||
relPath: 'Author/Book/new-title.epub',
|
||||
ino: 7778,
|
||||
ino: 7778n,
|
||||
sizeBytes: 2048,
|
||||
mtime: new Date('2024-01-02'),
|
||||
});
|
||||
@@ -2212,7 +2282,7 @@ describe('reassigned file metadata extraction', () => {
|
||||
bookId: 1,
|
||||
absolutePath: '/library/Author/Book/old-title.epub',
|
||||
relPath: 'Author/Book/old-title.epub',
|
||||
ino: 7778,
|
||||
ino: 7778n,
|
||||
sizeBytes: 1024,
|
||||
mtime: new Date('2024-01-01'),
|
||||
}),
|
||||
@@ -2234,7 +2304,7 @@ describe('reassigned file metadata extraction', () => {
|
||||
});
|
||||
|
||||
it('does not extract metadata when the file stays in the same book (not reassigned)', async () => {
|
||||
const fileStat = makeFileStat({ absolutePath: '/library/Author/Book/book.epub', relPath: 'Author/Book/book.epub', ino: 1001 });
|
||||
const fileStat = makeFileStat({ absolutePath: '/library/Author/Book/book.epub', relPath: 'Author/Book/book.epub', ino: 1001n });
|
||||
const repo = makeRepo({
|
||||
findBooksByLibraryFolder: vi
|
||||
.fn()
|
||||
@@ -2298,7 +2368,7 @@ describe('targeted book scan', () => {
|
||||
expect(mockBuildSingleCandidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clamps precision-unsafe root-level inode values to 0 before creating book files', async () => {
|
||||
it('preserves precision-unsafe root-level inode values before creating book files', async () => {
|
||||
const repo = makeRepo({
|
||||
findLibraryFolders: vi.fn().mockResolvedValue([{ id: 1, path: '/library', libraryId: 1 }]),
|
||||
});
|
||||
@@ -2310,7 +2380,7 @@ describe('targeted book scan', () => {
|
||||
expect(repo.createBookFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
absolutePath: '/library/book.epub',
|
||||
ino: 0,
|
||||
ino: 651896050678335552n,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -2466,8 +2536,8 @@ describe('book_per_file mode — runScan', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const file1 = makeFileStat({ absolutePath: '/library/Author/book1.epub', relPath: 'Author/book1.epub', ino: 5001 });
|
||||
const file2 = makeFileStat({ absolutePath: '/library/Author/book2.epub', relPath: 'Author/book2.epub', ino: 5002 });
|
||||
const file1 = makeFileStat({ absolutePath: '/library/Author/book1.epub', relPath: 'Author/book1.epub', ino: 5001n });
|
||||
const file2 = makeFileStat({ absolutePath: '/library/Author/book2.epub', relPath: 'Author/book2.epub', ino: 5002n });
|
||||
mockFindLooseCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/Author/book1.epub', [file1]), makeCandidate('/library/Author/book2.epub', [file2])],
|
||||
skippedDirs: new Set(),
|
||||
@@ -2496,8 +2566,8 @@ describe('book_per_file mode — runScan', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const epubFile = makeFileStat({ absolutePath: '/library/book.epub', relPath: 'book.epub', ino: 6001 });
|
||||
const pdfFile = makeFileStat({ absolutePath: '/library/book.pdf', relPath: 'book.pdf', ino: 6002 });
|
||||
const epubFile = makeFileStat({ absolutePath: '/library/book.epub', relPath: 'book.epub', ino: 6001n });
|
||||
const pdfFile = makeFileStat({ absolutePath: '/library/book.pdf', relPath: 'book.pdf', ino: 6002n });
|
||||
mockFindLooseCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/book.epub', [epubFile]), makeCandidate('/library/book.pdf', [pdfFile])],
|
||||
skippedDirs: new Set(),
|
||||
@@ -2611,7 +2681,7 @@ describe('book_per_file mode — scanBookFolder', () => {
|
||||
const movedFile = makeFileStat({
|
||||
absolutePath: '/library/Moved/Alpha.epub',
|
||||
relPath: 'Moved/Alpha.epub',
|
||||
ino: 3001,
|
||||
ino: 3001n,
|
||||
});
|
||||
const repo = makeRepo({
|
||||
findLibrarySettings: vi.fn().mockResolvedValue({
|
||||
@@ -2630,7 +2700,7 @@ describe('book_per_file mode — scanBookFolder', () => {
|
||||
id: 9,
|
||||
bookId: 7,
|
||||
absolutePath: '/library/Alpha.epub',
|
||||
ino: 3001,
|
||||
ino: 3001n,
|
||||
sizeBytes: 1024,
|
||||
mtime: new Date('2024-01-01'),
|
||||
fileHash: null,
|
||||
@@ -2647,7 +2717,7 @@ describe('book_per_file mode — scanBookFolder', () => {
|
||||
unchangedDirs: new Set(),
|
||||
dirMtimes: new Map(),
|
||||
});
|
||||
mockStat.mockRejectedValue(new Error('ENOENT'));
|
||||
mockStat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
const { service } = makeService(repo);
|
||||
|
||||
await (service as any).scanBookDirectory('/library/Moved', 1);
|
||||
@@ -2859,7 +2929,7 @@ describe('TOCTOU start lock', () => {
|
||||
|
||||
describe('inode zero guard', () => {
|
||||
it('skips global inode lookup in processFile when file has ino=0', async () => {
|
||||
const fileStat = makeFileStat({ ino: 0 });
|
||||
const fileStat = makeFileStat({ ino: 0n });
|
||||
mockFindCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/Author/Book', [fileStat])],
|
||||
skippedDirs: new Set(),
|
||||
|
||||
@@ -23,15 +23,7 @@ import { readdir, stat } from 'fs/promises';
|
||||
import { classifyFile, DEFAULT_FORMAT_PRIORITY, FileRole, isAudioFormat } from './lib/classify';
|
||||
import { computeFileHash } from './lib/hash';
|
||||
import { waitForStability } from './lib/stability';
|
||||
import {
|
||||
BookCandidate,
|
||||
FileStat,
|
||||
findBookCandidates,
|
||||
findLooseFileCandidates,
|
||||
buildSingleBookCandidate,
|
||||
clampIno,
|
||||
type WalkResult,
|
||||
} from './lib/walk';
|
||||
import { BookCandidate, FileStat, findBookCandidates, findLooseFileCandidates, buildSingleBookCandidate, type WalkResult } from './lib/walk';
|
||||
import { ScannerRepository } from './scanner.repository';
|
||||
import { assembleBookCards } from '../book/utils/assemble-book-cards';
|
||||
import { LIBRARY_METADATA_PRECEDENCE_DEFAULT } from '../library/library.constants';
|
||||
@@ -46,7 +38,7 @@ interface BookEntry {
|
||||
interface FileByPathEntry {
|
||||
id: number;
|
||||
bookId: number;
|
||||
ino: number;
|
||||
ino: bigint;
|
||||
sizeBytes: number | null;
|
||||
mtime: Date | null;
|
||||
fileHash: string | null;
|
||||
@@ -65,7 +57,7 @@ interface ScanLookupMaps {
|
||||
bookByFolderPath: Map<string, BookEntry>;
|
||||
booksByParentDir: Map<string, BookEntry[]>;
|
||||
fileByPath: Map<string, FileByPathEntry>;
|
||||
fileByIno: Map<number, FileByInoEntry>;
|
||||
fileByIno: Map<bigint, FileByInoEntry>;
|
||||
fileIdsByBookId: Map<number, Set<number>>;
|
||||
}
|
||||
|
||||
@@ -248,7 +240,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
id: number;
|
||||
bookId: number;
|
||||
absolutePath: string;
|
||||
ino: number;
|
||||
ino: bigint;
|
||||
sizeBytes: number | null;
|
||||
mtime: Date | null;
|
||||
fileHash: string | null;
|
||||
@@ -277,9 +269,9 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
]),
|
||||
);
|
||||
|
||||
const fileByIno = new Map<number, FileByInoEntry>(
|
||||
const fileByIno = new Map<bigint, FileByInoEntry>(
|
||||
knownFiles
|
||||
.filter((f) => f.ino !== 0)
|
||||
.filter((f) => f.ino !== 0n)
|
||||
.map((f) => [f.ino, { id: f.id, bookId: f.bookId, absolutePath: f.absolutePath, sizeBytes: f.sizeBytes, mtime: f.mtime }]),
|
||||
);
|
||||
|
||||
@@ -969,7 +961,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
{
|
||||
absolutePath: filePath,
|
||||
relPath: relative(libraryFolder.path, filePath),
|
||||
ino: clampIno(fileStat.ino),
|
||||
ino: fileStat.ino,
|
||||
sizeBytes: Number(fileStat.size),
|
||||
mtime: fileStat.mtime,
|
||||
format,
|
||||
@@ -1038,7 +1030,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
{
|
||||
absolutePath: filePath,
|
||||
relPath: relative(libraryFolder.path, filePath),
|
||||
ino: clampIno(fileStat.ino),
|
||||
ino: fileStat.ino,
|
||||
sizeBytes: Number(fileStat.size),
|
||||
mtime: fileStat.mtime,
|
||||
format,
|
||||
@@ -1663,7 +1655,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
bookByFolderPath: Map<string, BookEntry>,
|
||||
booksByParentDir: Map<string, BookEntry[]>,
|
||||
fileByPath: Map<string, FileByPathEntry>,
|
||||
fileByIno: Map<number, FileByInoEntry>,
|
||||
fileByIno: Map<bigint, FileByInoEntry>,
|
||||
counts: ScanCounts,
|
||||
candidateFolderPaths: Set<string>,
|
||||
): Promise<UpsertBookResult> {
|
||||
@@ -1672,7 +1664,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
for (const file of candidate.files) {
|
||||
const byPath = fileByPath.get(file.absolutePath);
|
||||
if (byPath) candidateOwnedBookIds.add(byPath.bookId);
|
||||
if (file.ino !== 0) {
|
||||
if (file.ino !== 0n) {
|
||||
const byIno = fileByIno.get(file.ino);
|
||||
if (byIno) candidateOwnedBookIds.add(byIno.bookId);
|
||||
}
|
||||
@@ -1708,7 +1700,15 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
return { ...survivor, folderPath: candidate.folderPath, created: false };
|
||||
}
|
||||
|
||||
const movedInLibrary = await this.tryReuseMovedBookInLibrary(candidate, libraryId, bookByFolderPath, fileByPath, fileByIno, counts);
|
||||
const movedInLibrary = await this.tryReuseMovedBookInLibrary(
|
||||
candidate,
|
||||
libraryId,
|
||||
bookByFolderPath,
|
||||
fileByPath,
|
||||
fileByIno,
|
||||
counts,
|
||||
candidateFolderPaths,
|
||||
);
|
||||
if (movedInLibrary) return { ...movedInLibrary, created: false };
|
||||
|
||||
const transferred = await this.tryTransferMissingBook(candidate, libraryId, libraryFolderId, bookByFolderPath, counts);
|
||||
@@ -1778,8 +1778,9 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
libraryId: number,
|
||||
bookByFolderPath: Map<string, BookEntry>,
|
||||
fileByPath: Map<string, FileByPathEntry>,
|
||||
fileByIno: Map<number, FileByInoEntry>,
|
||||
fileByIno: Map<bigint, FileByInoEntry>,
|
||||
counts: ScanCounts,
|
||||
candidateFolderPaths: Set<string>,
|
||||
): Promise<BookEntry | null> {
|
||||
const contentFiles = candidate.files.filter((file) => {
|
||||
const role = file.role ?? classifyFile(file.absolutePath).role;
|
||||
@@ -1791,7 +1792,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
let sourceBookId: number | null = null;
|
||||
|
||||
for (const file of contentFiles) {
|
||||
if (file.ino === 0) continue;
|
||||
if (file.ino === 0n) continue;
|
||||
const byIno = fileByIno.get(file.ino);
|
||||
if (!byIno || byIno.absolutePath === file.absolutePath) continue;
|
||||
sourceBookId = byIno.bookId;
|
||||
@@ -1808,7 +1809,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
if (code === 'ENOENT' || code === 'EACCES') continue;
|
||||
throw err;
|
||||
}
|
||||
const byHash = this.findLocalFileByHash(fileHash, file.absolutePath, fileByPath);
|
||||
const byHash = this.findLocalFileByHash(fileHash, file.absolutePath, file.sizeBytes, fileByPath);
|
||||
if (!byHash) continue;
|
||||
sourceBookId = byHash.bookId;
|
||||
break;
|
||||
@@ -1820,6 +1821,7 @@ 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;
|
||||
|
||||
await this.scannerRepo.updateBookFolderPath(sourceBook.id, candidate.folderPath);
|
||||
|
||||
@@ -1852,14 +1854,29 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
return null;
|
||||
}
|
||||
|
||||
private findLocalFileByHash(fileHash: string, absolutePath: string, fileByPath: Map<string, FileByPathEntry>): FileByPathEntry | null {
|
||||
private findLocalFileByHash(
|
||||
fileHash: string,
|
||||
absolutePath: string,
|
||||
sizeBytes: number,
|
||||
fileByPath: Map<string, FileByPathEntry>,
|
||||
): FileByPathEntry | null {
|
||||
for (const [path, entry] of fileByPath) {
|
||||
if (path === absolutePath) continue;
|
||||
if (entry.fileHash === fileHash) return entry;
|
||||
if (entry.fileHash === fileHash && entry.sizeBytes === sizeBytes) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
return code !== 'ENOENT' && code !== 'ENOTDIR';
|
||||
}
|
||||
}
|
||||
|
||||
private async filterBookIdsMissingOnDisk(bookIds: number[]): Promise<number[]> {
|
||||
if (bookIds.length === 0) return [];
|
||||
|
||||
@@ -1896,9 +1913,9 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
let sourceLibraryId: number | null = null;
|
||||
|
||||
for (const file of contentFiles) {
|
||||
if (file.ino === 0) continue;
|
||||
if (file.ino === 0n) continue;
|
||||
const byIno = await this.scannerRepo.findMissingBookFileWithContextByIno(file.ino);
|
||||
if (!byIno) continue;
|
||||
if (!byIno || (await this.pathExists(byIno.file.absolutePath))) continue;
|
||||
sourceBookId = byIno.file.bookId;
|
||||
sourceLibraryId = byIno.libraryId;
|
||||
break;
|
||||
@@ -1906,7 +1923,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
|
||||
if (sourceBookId == null) {
|
||||
for (const file of contentFiles) {
|
||||
if (file.ino === 0) continue;
|
||||
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);
|
||||
@@ -1929,14 +1946,14 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
const byHash = await this.scannerRepo.findMissingBookFileWithContextByHash(fileHash);
|
||||
if (byHash) {
|
||||
if (byHash && byHash.file.sizeBytes === file.sizeBytes && !(await this.pathExists(byHash.file.absolutePath))) {
|
||||
sourceBookId = byHash.file.bookId;
|
||||
sourceLibraryId = byHash.libraryId;
|
||||
break;
|
||||
}
|
||||
|
||||
const byHashAny = await this.scannerRepo.findBookFileWithContextByHash(fileHash);
|
||||
if (!byHashAny || byHashAny.file.absolutePath === file.absolutePath) continue;
|
||||
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;
|
||||
sourceBookId = byHashAny.file.bookId;
|
||||
@@ -1977,7 +1994,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
bookId: number,
|
||||
libraryFolderId: number,
|
||||
fileByPath: Map<string, FileByPathEntry>,
|
||||
fileByIno: Map<number, FileByInoEntry>,
|
||||
fileByIno: Map<bigint, FileByInoEntry>,
|
||||
counts: ScanCounts,
|
||||
isFirstScan: boolean,
|
||||
): Promise<ProcessedFileResult> {
|
||||
@@ -1988,7 +2005,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
|
||||
await waitForStability(fileStat.absolutePath, fileStat.mtime.getTime());
|
||||
|
||||
if (fileStat.ino !== 0) {
|
||||
if (fileStat.ino !== 0n) {
|
||||
const byInoResult = await this.resolveByLocalIno(fileStat, format, role, sortOrder, bookId, libraryFolderId, fileByPath, fileByIno, counts);
|
||||
if (byInoResult) return byInoResult;
|
||||
|
||||
@@ -2020,21 +2037,22 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
bookId: number,
|
||||
libraryFolderId: number,
|
||||
fileByPath: Map<string, FileByPathEntry>,
|
||||
fileByIno: Map<number, FileByInoEntry>,
|
||||
fileByIno: Map<bigint, FileByInoEntry>,
|
||||
counts: ScanCounts,
|
||||
): Promise<ProcessedFileResult & { fileId: number }> {
|
||||
const sizeUnchanged = fileStat.sizeBytes === byPath.sizeBytes;
|
||||
const mtimeUnchanged = fileStat.mtime.getTime() === byPath.mtime?.getTime();
|
||||
const inoUnchanged = fileStat.ino === byPath.ino;
|
||||
const reassigned = byPath.bookId !== bookId;
|
||||
const sortOrderUnchanged = sortOrder === byPath.sortOrder;
|
||||
|
||||
if (sizeUnchanged && mtimeUnchanged && !reassigned && sortOrderUnchanged) {
|
||||
if (sizeUnchanged && mtimeUnchanged && inoUnchanged && !reassigned && sortOrderUnchanged) {
|
||||
return { isNew: false, reassigned: false, changed: false, fileId: byPath.id };
|
||||
}
|
||||
|
||||
await waitForStability(fileStat.absolutePath, fileStat.mtime.getTime());
|
||||
|
||||
if (!sizeUnchanged || !mtimeUnchanged || reassigned) {
|
||||
if (!sizeUnchanged || !mtimeUnchanged || !inoUnchanged || reassigned) {
|
||||
await this.scannerRepo.updateBookFile(byPath.id, {
|
||||
...(reassigned && { bookId }),
|
||||
libraryFolderId,
|
||||
@@ -2064,7 +2082,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
fileHash: byPath.fileHash,
|
||||
sortOrder,
|
||||
});
|
||||
if (fileStat.ino !== 0) {
|
||||
if (fileStat.ino !== 0n) {
|
||||
fileByIno.set(fileStat.ino, {
|
||||
id: byPath.id,
|
||||
bookId,
|
||||
@@ -2084,13 +2102,14 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
bookId: number,
|
||||
libraryFolderId: number,
|
||||
fileByPath: Map<string, FileByPathEntry>,
|
||||
fileByIno: Map<number, FileByInoEntry>,
|
||||
fileByIno: Map<bigint, FileByInoEntry>,
|
||||
counts: ScanCounts,
|
||||
): Promise<(ProcessedFileResult & { fileId: number }) | null> {
|
||||
const byIno = fileByIno.get(fileStat.ino);
|
||||
if (!byIno) return null;
|
||||
|
||||
const oldAbsolutePath = byIno.absolutePath;
|
||||
if (await this.pathExists(oldAbsolutePath)) return null;
|
||||
const sizeUnchanged = fileStat.sizeBytes === byIno.sizeBytes;
|
||||
const mtimeUnchanged = fileStat.mtime.getTime() === byIno.mtime?.getTime();
|
||||
await this.scannerRepo.updateBookFile(byIno.id, {
|
||||
@@ -2130,7 +2149,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
bookId: number,
|
||||
libraryFolderId: number,
|
||||
fileByPath: Map<string, FileByPathEntry>,
|
||||
fileByIno: Map<number, FileByInoEntry>,
|
||||
fileByIno: Map<bigint, FileByInoEntry>,
|
||||
counts: ScanCounts,
|
||||
): Promise<(ProcessedFileResult & { fileId: number }) | null> {
|
||||
let globalByIno = await this.scannerRepo.findBookFileWithContextByIno(fileStat.ino);
|
||||
@@ -2149,6 +2168,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (await this.pathExists(globalByIno.file.absolutePath)) return null;
|
||||
|
||||
const oldAbsolutePath = globalByIno.file.absolutePath;
|
||||
const sizeUnchanged = fileStat.sizeBytes === globalByIno.file.sizeBytes;
|
||||
@@ -2206,7 +2226,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
bookId: number,
|
||||
libraryFolderId: number,
|
||||
fileByPath: Map<string, FileByPathEntry>,
|
||||
fileByIno: Map<number, FileByInoEntry>,
|
||||
fileByIno: Map<bigint, FileByInoEntry>,
|
||||
counts: ScanCounts,
|
||||
isFirstScan: boolean,
|
||||
): Promise<ProcessedFileResult> {
|
||||
@@ -2226,7 +2246,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
|
||||
if (!isFirstScan) {
|
||||
const byHash = await this.scannerRepo.findBookFileByHash(fileHash, libraryFolderId);
|
||||
if (byHash) {
|
||||
if (byHash && byHash.sizeBytes === fileStat.sizeBytes && !(await this.pathExists(byHash.absolutePath))) {
|
||||
const oldAbsolutePath = byHash.absolutePath;
|
||||
await this.scannerRepo.updateBookFile(byHash.id, {
|
||||
bookId,
|
||||
@@ -2258,7 +2278,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
fileHash: byHash.fileHash,
|
||||
sortOrder,
|
||||
});
|
||||
if (fileStat.ino !== 0) {
|
||||
if (fileStat.ino !== 0n) {
|
||||
fileByIno.set(fileStat.ino, {
|
||||
id: byHash.id,
|
||||
bookId,
|
||||
@@ -2282,7 +2302,9 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
if (
|
||||
globalByHash &&
|
||||
globalByHash.file.absolutePath !== fileStat.absolutePath &&
|
||||
(globalByHash.file.bookId === bookId || globalByHash.bookStatus === 'missing')
|
||||
globalByHash.file.sizeBytes === fileStat.sizeBytes &&
|
||||
(globalByHash.file.bookId === bookId || globalByHash.bookStatus === 'missing') &&
|
||||
!(await this.pathExists(globalByHash.file.absolutePath))
|
||||
) {
|
||||
const oldAbsolutePath = globalByHash.file.absolutePath;
|
||||
await this.scannerRepo.updateBookFile(globalByHash.file.id, {
|
||||
@@ -2316,7 +2338,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
fileHash,
|
||||
sortOrder,
|
||||
});
|
||||
if (fileStat.ino !== 0) {
|
||||
if (fileStat.ino !== 0n) {
|
||||
fileByIno.set(fileStat.ino, {
|
||||
id: globalByHash.file.id,
|
||||
bookId,
|
||||
@@ -2379,7 +2401,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
fileHash,
|
||||
sortOrder,
|
||||
});
|
||||
if (fileStat.ino !== 0) {
|
||||
if (fileStat.ino !== 0n) {
|
||||
fileByIno.set(fileStat.ino, {
|
||||
id: created.id,
|
||||
bookId,
|
||||
@@ -2396,7 +2418,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
retainedFileIds: Set<number>,
|
||||
fileIdsByBookId: Map<number, Set<number>>,
|
||||
fileByPath: Map<string, FileByPathEntry>,
|
||||
fileByIno: Map<number, FileByInoEntry>,
|
||||
fileByIno: Map<bigint, FileByInoEntry>,
|
||||
counts: { added: number; updated: number },
|
||||
): Promise<void> {
|
||||
// Use in-memory index instead of DB query per book
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('UploadProcessorService', () => {
|
||||
libraryFolderId: 2,
|
||||
absolutePath: '/folder/book.epub',
|
||||
relPath: 'book/book.epub',
|
||||
ino: 111,
|
||||
ino: 111n,
|
||||
sizeBytes: 12345,
|
||||
fileHash: 'hash-abc',
|
||||
format: 'epub',
|
||||
@@ -134,19 +134,19 @@ describe('UploadProcessorService', () => {
|
||||
expect(orchestrator.scheduleImportedBooksIfEligible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clamps oversized MergerFS inode values to 0 before persisting', async () => {
|
||||
it('preserves oversized MergerFS inode values before persisting', async () => {
|
||||
mockStat.mockResolvedValueOnce({ ino: 14351917807348929000n, mtime: new Date('2024-01-01') } as Awaited<ReturnType<typeof stat>>);
|
||||
|
||||
await service.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 12345);
|
||||
|
||||
expect(insertBookFilesValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ino: 0,
|
||||
ino: 14351917807348929000n,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps oversized MergerFS inode values to 0 in existing-book upserts', async () => {
|
||||
it('preserves oversized MergerFS inode values in existing-book upserts', async () => {
|
||||
selectLimit.mockResolvedValueOnce([{ id: 99 }]);
|
||||
mockStat.mockResolvedValueOnce({ ino: 14351917807348929000n, mtime: new Date('2024-01-01') } as Awaited<ReturnType<typeof stat>>);
|
||||
|
||||
@@ -155,20 +155,20 @@ describe('UploadProcessorService', () => {
|
||||
expect(insertBookFilesOnConflict).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
set: expect.objectContaining({
|
||||
ino: 0,
|
||||
ino: 14351917807348929000n,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps precision-unsafe inodes to 0 before persisting', async () => {
|
||||
it('preserves precision-unsafe inodes exactly before persisting', async () => {
|
||||
mockStat.mockResolvedValueOnce({ ino: 651896050678335552n, mtime: new Date('2024-01-01') } as Awaited<ReturnType<typeof stat>>);
|
||||
|
||||
await service.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 12345);
|
||||
|
||||
expect(insertBookFilesValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ino: 0,
|
||||
ino: 651896050678335552n,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import { bookFiles, bookMetadata, books } from '../../db/schema';
|
||||
import { BookMetadataFetchOrchestratorService } from '../book-metadata-fetch/book-metadata-fetch-orchestrator.service';
|
||||
import { MetadataService } from '../metadata/metadata.service';
|
||||
import { computeFileHash } from '../scanner/lib/hash';
|
||||
import { clampIno } from '../scanner/lib/walk';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -54,7 +53,7 @@ export class UploadProcessorService {
|
||||
sizeBytes: number,
|
||||
): Promise<{ bookId: number; created: boolean }> {
|
||||
const [fileStat, fileHash] = await Promise.all([stat(absolutePath, { bigint: true }), computeFileHash(absolutePath)]);
|
||||
const safeIno = clampIno(fileStat.ino);
|
||||
const ino = fileStat.ino;
|
||||
|
||||
const result = await this.db.transaction(async (tx) => {
|
||||
const [existingBook] = await tx
|
||||
@@ -69,7 +68,7 @@ export class UploadProcessorService {
|
||||
libraryFolderId,
|
||||
absolutePath,
|
||||
relPath,
|
||||
ino: safeIno,
|
||||
ino,
|
||||
sizeBytes,
|
||||
mtime: fileStat.mtime,
|
||||
fileHash,
|
||||
@@ -81,7 +80,7 @@ export class UploadProcessorService {
|
||||
.values(fileValues)
|
||||
.onConflictDoUpdate({
|
||||
target: bookFiles.absolutePath,
|
||||
set: { bookId: existingBook.id, libraryFolderId, relPath, ino: safeIno, sizeBytes, mtime: fileStat.mtime, fileHash, format },
|
||||
set: { bookId: existingBook.id, libraryFolderId, relPath, ino, sizeBytes, mtime: fileStat.mtime, fileHash, format },
|
||||
})
|
||||
.returning({ id: bookFiles.id });
|
||||
if (!file) throw new InternalServerErrorException('Failed to create book file');
|
||||
@@ -101,7 +100,7 @@ export class UploadProcessorService {
|
||||
libraryFolderId,
|
||||
absolutePath,
|
||||
relPath,
|
||||
ino: safeIno,
|
||||
ino,
|
||||
sizeBytes,
|
||||
mtime: fileStat.mtime,
|
||||
fileHash,
|
||||
|
||||
@@ -4,7 +4,6 @@ vi.mock('fs/promises', async () => {
|
||||
});
|
||||
|
||||
vi.mock('../scanner/lib/hash', () => ({ computeFileHash: vi.fn() }));
|
||||
vi.mock('../scanner/lib/walk', () => ({ clampIno: vi.fn() }));
|
||||
|
||||
vi.mock('../metadata/lib/epub', () => ({ extractEpubMetadata: vi.fn() }));
|
||||
vi.mock('../metadata/lib/cbz-metadata', () => ({ extractCbzMetadata: vi.fn(), extractCbrMetadata: vi.fn(), extractCb7Metadata: vi.fn() }));
|
||||
@@ -18,7 +17,6 @@ import { extractCbzMetadata } from '../metadata/lib/cbz-metadata';
|
||||
import { parseMobiFile } from '../metadata/lib/mobi-parser';
|
||||
import { parsePdfFile } from '../metadata/lib/pdf-parser';
|
||||
import { computeFileHash } from '../scanner/lib/hash';
|
||||
import { clampIno } from '../scanner/lib/walk';
|
||||
|
||||
import { UploadService } from './upload.service';
|
||||
|
||||
@@ -29,7 +27,6 @@ const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction<typeof extra
|
||||
const mockParseMobiFile = parseMobiFile as MockedFunction<typeof parseMobiFile>;
|
||||
const mockParsePdfFile = parsePdfFile as MockedFunction<typeof parsePdfFile>;
|
||||
const mockComputeFileHash = computeFileHash as MockedFunction<typeof computeFileHash>;
|
||||
const mockClampIno = clampIno as MockedFunction<typeof clampIno>;
|
||||
|
||||
function selectChain(rows: unknown[]) {
|
||||
const whereResult: PromiseLike<unknown[]> & { limit: vi.Mock } = {
|
||||
@@ -125,7 +122,6 @@ describe('UploadService', () => {
|
||||
updateSet.mockReturnValue({ where: updateWhere });
|
||||
updateWhere.mockResolvedValue(undefined);
|
||||
mockComputeFileHash.mockResolvedValue('hash-abc');
|
||||
mockClampIno.mockReturnValue(12345);
|
||||
mockStat.mockResolvedValue({ ino: 12345n, mtime: new Date('2025-01-01') } as any);
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ 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 { clampIno } from '../scanner/lib/walk';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -188,7 +187,7 @@ export class UploadService {
|
||||
shouldCleanupDestination = false;
|
||||
|
||||
const fileStat = await stat(destination, { bigint: true });
|
||||
const safeIno = clampIno(fileStat.ino);
|
||||
const ino = fileStat.ino;
|
||||
const relPath = relative(bookRow.libraryFolderPath, destination);
|
||||
|
||||
const [inserted] = await this.db
|
||||
@@ -198,7 +197,7 @@ export class UploadService {
|
||||
libraryFolderId: bookRow.libraryFolderId,
|
||||
absolutePath: destination,
|
||||
relPath,
|
||||
ino: safeIno,
|
||||
ino,
|
||||
sizeBytes,
|
||||
mtime: fileStat.mtime,
|
||||
fileHash,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mkdir, stat, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { bookFiles, books } from '../src/db/schema';
|
||||
import {
|
||||
closeScannerE2EContext,
|
||||
createScannerE2EContext,
|
||||
loadLibraryBookState,
|
||||
seedLibrary,
|
||||
triggerAndWaitForLibraryScan,
|
||||
type ScannerE2EContext,
|
||||
} from './e2e/scanner/scanner-harness';
|
||||
|
||||
const mergerFsRoot = process.env.MERGERFS_TEST_ROOT;
|
||||
|
||||
describe.skipIf(!mergerFsRoot)('MergerFS inode scanning', () => {
|
||||
let ctx: ScannerE2EContext;
|
||||
let libraryId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
await mkdir(join(mergerFsRoot!, 'ExistingBook'), { recursive: true });
|
||||
await writeFile(join(mergerFsRoot!, 'ExistingBook', 'book.epub'), 'shared-content');
|
||||
ctx = await createScannerE2EContext();
|
||||
({ libraryId } = await seedLibrary(ctx.db, {
|
||||
rootPath: mergerFsRoot!,
|
||||
mode: 'book_per_folder',
|
||||
name: 'mergerfs-inode-e2e',
|
||||
}));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (ctx) await closeScannerE2EContext(ctx);
|
||||
});
|
||||
|
||||
it('persists exact oversized inodes and keeps identical files in distinct folders', async () => {
|
||||
const existingPath = join(mergerFsRoot!, 'ExistingBook', 'book.epub');
|
||||
const existingStat = await stat(existingPath, { bigint: true });
|
||||
const maxSafeIno = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
expect(existingStat.ino < -maxSafeIno || existingStat.ino > maxSafeIno).toBe(true);
|
||||
|
||||
await triggerAndWaitForLibraryScan(ctx, libraryId);
|
||||
|
||||
const [storedExisting] = await ctx.db
|
||||
.select({ ino: bookFiles.ino })
|
||||
.from(bookFiles)
|
||||
.innerJoin(books, eq(books.id, bookFiles.bookId))
|
||||
.where(eq(books.libraryId, libraryId));
|
||||
expect(storedExisting.ino).toBe(existingStat.ino);
|
||||
|
||||
await mkdir(join(mergerFsRoot!, 'NewBook'), { recursive: true });
|
||||
await writeFile(join(mergerFsRoot!, 'NewBook', 'book.epub'), 'shared-content');
|
||||
await triggerAndWaitForLibraryScan(ctx, libraryId);
|
||||
|
||||
const state = await loadLibraryBookState(ctx.db, libraryId);
|
||||
expect(state).toHaveLength(2);
|
||||
expect(state.map((book) => book.folderPath)).toEqual([join(mergerFsRoot!, 'ExistingBook'), join(mergerFsRoot!, 'NewBook')]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user