fix(email): keep attachment filenames in sync after rename

Keep relative file paths synchronized during manual renames and repair
stale rows during watcher and full-scan reconciliation. Derive email
attachment names from the file being streamed.

Fixes #1286
This commit is contained in:
neon
2026-09-06 14:26:29 +00:00
parent 5052f170a9
commit 836186159a
12 changed files with 217 additions and 35 deletions
@@ -53,6 +53,25 @@ function makeInsertChain() {
}
describe('BookRepository', () => {
it('updates absolute and relative book file paths together', async () => {
const where = vi.fn().mockResolvedValue(undefined);
const set = vi.fn().mockReturnValue({ where });
const db = { update: vi.fn().mockReturnValue({ set }) };
const repo = new BookRepository(db as never);
await repo.updateBookFile(9, {
absolutePath: '/library/Author/new.epub',
relPath: 'Author/new.epub',
});
expect(set).toHaveBeenCalledWith({
absolutePath: '/library/Author/new.epub',
relPath: 'Author/new.epub',
updatedAt: expect.any(Date),
});
expect(where).toHaveBeenCalledOnce();
});
it('runs callbacks inside db transactions', async () => {
const db = {
transaction: vi.fn((callback: (tx: { id: string }) => Promise<string>) => callback({ id: 'tx-1' })),
@@ -431,7 +450,17 @@ describe('BookRepository', () => {
const libraryIdChain = makeSelectChain('limit', [{ libraryId: 5 }]);
const missingLibraryChain = makeSelectChain('limit', []);
const fileByIdChain = makeSelectChain('limit', [
{ id: 9, absolutePath: '/books/a.epub', format: 'epub', bookId: 1, libraryId: 2, fileHash: null, sizeBytes: null },
{
id: 9,
absolutePath: '/books/a.epub',
relPath: 'a.epub',
libraryFolderPath: '/books',
format: 'epub',
bookId: 1,
libraryId: 2,
fileHash: null,
sizeBytes: null,
},
]);
const missingFileChain = makeSelectChain('limit', []);
const progressChain = makeSelectChain('limit', [{ percentage: 12 }]);
@@ -476,6 +505,8 @@ describe('BookRepository', () => {
await expect(repo.findFileById(9)).resolves.toEqual({
id: 9,
absolutePath: '/books/a.epub',
relPath: 'a.epub',
libraryFolderPath: '/books',
format: 'epub',
bookId: 1,
libraryId: 2,
+7 -1
View File
@@ -1459,16 +1459,19 @@ export class BookRepository {
.select({
id: bookFiles.id,
absolutePath: bookFiles.absolutePath,
relPath: bookFiles.relPath,
format: bookFiles.format,
role: bookFiles.role,
bookId: bookFiles.bookId,
libraryId: books.libraryId,
libraryFolderPath: libraryFolders.path,
fileHash: bookFiles.fileHash,
sizeBytes: bookFiles.sizeBytes,
durationSeconds: bookFiles.durationSeconds,
})
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.innerJoin(libraryFolders, eq(libraryFolders.id, bookFiles.libraryFolderId))
.where(eq(bookFiles.id, fileId))
.limit(1);
return file ?? null;
@@ -1478,7 +1481,10 @@ export class BookRepository {
await this.db.delete(bookFiles).where(eq(bookFiles.id, fileId));
}
async updateBookFile(fileId: number, data: { format?: string | null; role?: string; absolutePath?: string; sizeBytes?: number }): Promise<void> {
async updateBookFile(
fileId: number,
data: { format?: string | null; role?: string; absolutePath?: string; relPath?: string | null; sizeBytes?: number },
): Promise<void> {
await this.db
.update(bookFiles)
.set({ ...data, updatedAt: new Date() })
+32 -5
View File
@@ -5129,7 +5129,16 @@ describe('BookService', () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ id: 1 });
const fileId = 100;
const file = { absolutePath: '/path/to/old.epub', filename: 'old.epub', format: 'epub', role: 'content', bookId: 10, libraryId: 1 };
const file = {
absolutePath: '/library/Author/old.epub',
relPath: 'Author/old.epub',
libraryFolderPath: '/library',
filename: 'old.epub',
format: 'epub',
role: 'content',
bookId: 10,
libraryId: 1,
};
bookRepo.findFileById = vi.fn().mockResolvedValue(file);
libraryService.checkLibraryAccess = vi.fn().mockResolvedValue(true);
@@ -5139,9 +5148,10 @@ describe('BookService', () => {
await service.renameFile(fileId, { filename: 'new.epub' }, user);
expect(rename).toHaveBeenCalledWith('/path/to/old.epub', '/path/to/new.epub');
expect(rename).toHaveBeenCalledWith('/library/Author/old.epub', '/library/Author/new.epub');
expect(bookRepo.updateBookFile).toHaveBeenCalledWith(fileId, {
absolutePath: '/path/to/new.epub',
absolutePath: '/library/Author/new.epub',
relPath: 'Author/new.epub',
});
});
@@ -5149,7 +5159,15 @@ describe('BookService', () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ id: 1 });
const fileId = 100;
const file = { absolutePath: '/path/to/old.epub', filename: 'old.epub', format: 'epub', role: 'content', bookId: 10, libraryId: 1 };
const file = {
absolutePath: '/path/to/old.epub',
libraryFolderPath: '/path/to',
filename: 'old.epub',
format: 'epub',
role: 'content',
bookId: 10,
libraryId: 1,
};
bookRepo.findFileById = vi.fn().mockResolvedValue(file);
libraryService.checkLibraryAccess = vi.fn().mockResolvedValue(true);
@@ -5165,7 +5183,15 @@ describe('BookService', () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ id: 1 });
const fileId = 100;
const file = { absolutePath: '/path/to/old.epub', filename: 'old.epub', format: 'epub', role: 'content', bookId: 10, libraryId: 1 };
const file = {
absolutePath: '/path/to/old.epub',
libraryFolderPath: '/path/to',
filename: 'old.epub',
format: 'epub',
role: 'content',
bookId: 10,
libraryId: 1,
};
bookRepo.findFileById = vi.fn().mockResolvedValue(file);
libraryService.checkLibraryAccess = vi.fn().mockResolvedValue(true);
@@ -5178,6 +5204,7 @@ describe('BookService', () => {
expect(rename).not.toHaveBeenCalled();
expect(bookRepo.updateBookFile).toHaveBeenCalledWith(fileId, {
absolutePath: undefined,
relPath: undefined,
});
});
});
+2 -1
View File
@@ -30,7 +30,7 @@ import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../m
import { parseFb2File } from '../metadata/lib/fb2-parser';
import { parseMobiFile } from '../metadata/lib/mobi-parser';
import { parsePdfFile, type PdfParseWarning } from '../metadata/lib/pdf-parser';
import { basename, dirname, extname, join } from 'path';
import { basename, dirname, extname, join, relative } from 'path';
import {
BOOK_METADATA_LOCK_FIELDS,
@@ -1463,6 +1463,7 @@ export class BookService {
await this.bookRepo.updateBookFile(fileId, {
absolutePath: newAbsolutePath !== file.absolutePath ? newAbsolutePath : undefined,
relPath: newAbsolutePath !== file.absolutePath ? relative(file.libraryFolderPath, newAbsolutePath) : undefined,
});
this.logger.log(`[${event}] [end] fileId=${fileId} durationMs=${Date.now() - startedAt} - rename file completed`);
@@ -279,6 +279,20 @@ describe('EmailSendOrchestrator', () => {
(transportService.buildTransporter as vi.Mock).mockReturnValue(mockTransporter);
});
it('uses the streamed file path for the attachment filename when relPath is stale', async () => {
const task = { recipientEmail: 'test@test.com' } as any;
const file = { absolutePath: '/library/Author/new.epub', relPath: 'Author/old.epub', format: 'EPUB' } as any;
await (orchestrator as any).dispatchSend(400, {}, task, file, 'Subject', 'Body', 0);
expect(fs.createReadStream).toHaveBeenCalledWith('/library/Author/new.epub');
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [{ filename: 'new.epub', content: 'mock-stream' }],
}),
);
});
it('should send email and mark log as sent', async () => {
const task = { recipientEmail: 'test@test.com' } as any;
const file = { absolutePath: '/test.mobi', relPath: 'test.mobi' } as any;
@@ -377,20 +391,20 @@ describe('EmailSendOrchestrator', () => {
});
describe('buildAttachmentFilename', () => {
it('should build filename from relPath and format', () => {
const file = { relPath: 'Library/Author/Book.epub', format: 'EPUB' } as any;
it('should build filename from absolutePath instead of stale relPath', () => {
const file = { absolutePath: '/library/Author/New Name.epub', relPath: 'Library/Author/Old Name.epub', format: 'EPUB' } as any;
const filename = (orchestrator as any).buildAttachmentFilename(file);
expect(filename).toBe('Book.epub');
expect(filename).toBe('New Name.epub');
});
it('should use "book" if relPath is missing', () => {
const file = { relPath: null, format: 'PDF' } as any;
const file = { absolutePath: '/library/book.pdf', relPath: null, format: 'PDF' } as any;
const filename = (orchestrator as any).buildAttachmentFilename(file);
expect(filename).toBe('book.pdf');
});
it('should handle missing format', () => {
const file = { relPath: 'some/book', format: null } as any;
const file = { absolutePath: '/library/some/book', relPath: 'some/old-book', format: null } as any;
const filename = (orchestrator as any).buildAttachmentFilename(file);
expect(filename).toBe('book');
});
@@ -1,5 +1,6 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { createReadStream } from 'fs';
import { basename } from 'path';
import { NotificationType } from '@bookorbit/types';
import type { BookFile } from '../../db/schema';
@@ -311,14 +312,7 @@ export class EmailSendOrchestrator {
}
private buildAttachmentFilename(file: BookFile): string {
const ext = file.format ? `.${file.format.toLowerCase()}` : '';
const base = file.relPath
? (file.relPath
.split('/')
.pop()
?.replace(/\.[^.]+$/, '') ?? 'book')
: 'book';
return `${base}${ext}`;
return basename(file.absolutePath);
}
private buildFromHeader(fromName: string | null | undefined, fromAddress: string | null | undefined): string | undefined {
@@ -393,8 +393,9 @@ describe('handleCreate — file', () => {
const fileStat = makeFileStat();
mockStat.mockResolvedValue(fileStat);
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
file: { id: 1, bookId: 5, ino: fileStat.ino, sizeBytes: Number(fileStat.size), mtime: fileStat.mtime },
file: { id: 1, bookId: 5, relPath: 'Author/book.epub', ino: fileStat.ino, sizeBytes: Number(fileStat.size), mtime: fileStat.mtime },
libraryId: 1,
libraryFolderPath: '/books',
} as any);
mockRepo.findBookById.mockResolvedValue({ id: 5, status: 'present' } as any);
mockRepo.findMissingBookByFolderPath.mockResolvedValue(null);
@@ -402,6 +403,24 @@ describe('handleCreate — file', () => {
const result = await makeService().handleCreate('/books/Author/book.epub');
expect(result).toEqual({ type: 'noop' });
expect(mockRepo.updateBookFile).not.toHaveBeenCalled();
});
it('repairs stale relPath when a watcher event matches the current file state', async () => {
const fileStat = makeFileStat();
mockStat.mockResolvedValue(fileStat);
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
file: { id: 1, bookId: 5, relPath: 'Author/old.epub', ino: fileStat.ino, sizeBytes: Number(fileStat.size), mtime: fileStat.mtime },
libraryId: 1,
libraryFolderPath: '/books',
} as any);
mockRepo.findBookById.mockResolvedValue({ id: 5, status: 'present' } as any);
mockRepo.findMissingBookByFolderPath.mockResolvedValue(null);
const result = await makeService().handleCreate('/books/Author/book.epub');
expect(result).toEqual({ type: 'noop' });
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(1, { relPath: 'Author/book.epub' });
});
it.each([
@@ -421,6 +440,7 @@ describe('handleCreate — file', () => {
...persistedOverrides,
},
libraryId: 1,
libraryFolderPath: '/books',
} as any);
mockRepo.findBookById.mockResolvedValue({ id: 5, status: 'present' } as any);
mockRepo.findMissingBookByFolderPath.mockResolvedValue(null);
@@ -436,6 +456,7 @@ describe('handleCreate — file', () => {
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
file: { id: 42, bookId: 10 },
libraryId: 3,
libraryFolderPath: '/books',
} as any);
mockRepo.findBookById.mockResolvedValue({ id: 10, status: 'missing', libraryId: 3 } as any);
@@ -454,6 +475,7 @@ describe('handleCreate — file', () => {
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
file: { id: 42, bookId: 10 },
libraryId: 3,
libraryFolderPath: '/books',
} as any);
mockRepo.findBookById.mockResolvedValue({ id: 10, status: 'missing', libraryId: 3 } as any);
@@ -468,6 +490,7 @@ describe('handleCreate — file', () => {
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
file: { id: 42, bookId: 10 },
libraryId: 3,
libraryFolderPath: '/books',
} as any);
mockRepo.findBookById.mockResolvedValue({ id: 10, status: 'missing', libraryId: 3 } as any);
@@ -482,6 +505,7 @@ describe('handleCreate — file', () => {
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
file: { id: 42, bookId: 10 },
libraryId: 3,
libraryFolderPath: '/books',
} as any);
// Own book is missing - should be restored directly
mockRepo.findBookById.mockResolvedValue({ id: 10, status: 'missing', libraryId: 3 } as any);
@@ -502,6 +526,7 @@ describe('handleCreate — file', () => {
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
file: { id: 42, bookId: 10 },
libraryId: 3,
libraryFolderPath: '/books',
} as any);
// Own book is present - not missing
mockRepo.findBookById.mockResolvedValue({ id: 10, status: 'present', libraryId: 3 } as any);
@@ -102,10 +102,11 @@ export class FileEventProcessorService {
const existing = await this.scannerRepo.findBookFileByAbsolutePath(absolutePath, scopeLibraryId);
if (existing) {
const currentRelPath = relative(existing.libraryFolderPath, absolutePath);
// Check the file's own book first before searching for any missing book
const ownBook = await this.scannerRepo.findBookById(existing.file.bookId);
if (ownBook?.status === 'missing') {
await this.scannerRepo.updateBookFile(existing.file.id, this.statToFileInfo(fileStat));
await this.scannerRepo.updateBookFile(existing.file.id, { ...this.statToFileInfo(fileStat), relPath: currentRelPath });
await this.refreshPrimaryFile(ownBook.id, existing.libraryId);
await this.scannerRepo.markBooksAsPresent([ownBook.id]);
this.logger.log(
@@ -119,10 +120,14 @@ export class FileEventProcessorService {
(await this.scannerRepo.findMissingBookByFolderPath(dirname(absolutePath), scopeLibraryId)) ??
(await this.scannerRepo.findMissingBookByFolderPath(absolutePath, scopeLibraryId));
if (!book) {
return this.fileStateMatches(existing.file, fileStat) ? { type: 'noop' } : { type: 'scan-required', scope: 'file' };
if (!this.fileStateMatches(existing.file, fileStat)) return { type: 'scan-required', scope: 'file' };
if (existing.file.relPath !== currentRelPath) {
await this.scannerRepo.updateBookFile(existing.file.id, { relPath: currentRelPath });
}
return { type: 'noop' };
}
await this.scannerRepo.updateBookFile(existing.file.id, this.statToFileInfo(fileStat));
await this.scannerRepo.updateBookFile(existing.file.id, { ...this.statToFileInfo(fileStat), relPath: currentRelPath });
await this.refreshPrimaryFile(book.id, book.libraryId);
await this.scannerRepo.markBooksAsPresent([book.id]);
this.logger.log(
@@ -161,9 +161,11 @@ 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: 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: 90, bookId: 1, absolutePath: '/books/A/book.epub', relPath: 'A/book.epub', ino: 999n, sizeBytes: 10, mtime: new Date(), fileHash: 'x' },
]);
queues.select.push([{ id: 15, bookId: 1, libraryId: 4, primaryFileId: 90, libraryFolderPath: '/books' }]);
queues.select.push([{ id: 16, bookId: 2, libraryId: 5, primaryFileId: 91, libraryFolderPath: '/books' }]);
queues.select.push([{ id: 3, folderPath: '/books/A' }]);
queues.select.push([{ id: 4, folderPath: '/books/B' }]);
queues.select.push([{ id: 8, folderPath: '/books/A', status: 'missing' }]);
@@ -175,10 +177,31 @@ 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: 999n, sizeBytes: 10, mtime: expect.any(Date), fileHash: 'x' },
{
id: 90,
bookId: 1,
absolutePath: '/books/A/book.epub',
relPath: '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 });
await expect(repo.findBookFileByAbsolutePath('/books/A/book.epub')).resolves.toEqual({
id: 15,
bookId: 1,
libraryId: 4,
primaryFileId: 90,
libraryFolderPath: '/books',
});
await expect(repo.findBookFileByAbsolutePath('/books/B/book.epub', 5)).resolves.toEqual({
id: 16,
bookId: 2,
libraryId: 5,
primaryFileId: 91,
libraryFolderPath: '/books',
});
await expect(repo.findBooksByFolderPath('/books/A')).resolves.toEqual([{ id: 3, folderPath: '/books/A' }]);
await expect(repo.findBooksByFolderPath('/books/B', 5)).resolves.toEqual([{ id: 4, folderPath: '/books/B' }]);
await expect(repo.findMissingBookByFolderPath('/books/A')).resolves.toEqual({ id: 8, folderPath: '/books/A', status: 'missing' });
@@ -238,6 +238,7 @@ export class ScannerRepository {
id: bookFiles.id,
bookId: bookFiles.bookId,
absolutePath: bookFiles.absolutePath,
relPath: bookFiles.relPath,
ino: bookFiles.ino,
sizeBytes: bookFiles.sizeBytes,
mtime: bookFiles.mtime,
@@ -282,9 +283,10 @@ export class ScannerRepository {
const whereClause =
libraryId == null ? eq(bookFiles.absolutePath, absolutePath) : and(eq(bookFiles.absolutePath, absolutePath), eq(books.libraryId, libraryId));
const [row] = await this.db
.select({ file: bookFiles, libraryId: books.libraryId, primaryFileId: books.primaryFileId })
.select({ file: bookFiles, libraryId: books.libraryId, primaryFileId: books.primaryFileId, libraryFolderPath: libraryFolders.path })
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.innerJoin(libraryFolders, eq(libraryFolders.id, bookFiles.libraryFolderId))
.where(whereClause)
.limit(1);
return row ?? null;
@@ -1170,6 +1170,38 @@ describe('file identity resolution', () => {
expect(repo.createBookFile).not.toHaveBeenCalled();
});
it('repairs a stale relPath when the absolute path and file state are unchanged', async () => {
const mtime = new Date('2024-01-01');
const fileStat = makeFileStat({ absolutePath: '/library/Author/Book/new.epub', relPath: 'Author/Book/new.epub', mtime });
const repo = makeRepo({
findBookFilesByLibraryFolder: vi.fn().mockResolvedValue([
makeBookFile({
absolutePath: fileStat.absolutePath,
relPath: 'Author/Book/old.epub',
mtime,
sizeBytes: fileStat.sizeBytes,
}),
]),
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(),
});
const done = awaitScan(repo);
const { service } = makeService(repo);
await service.startScan(1, 'manual');
await done;
expect(repo.updateBookFile).toHaveBeenCalledWith(1, expect.objectContaining({ relPath: 'Author/Book/new.epub' }));
expect(repo.createBookFile).not.toHaveBeenCalled();
});
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;
+25 -3
View File
@@ -45,6 +45,7 @@ interface BookEntry {
interface FileByPathEntry {
id: number;
bookId: number;
relPath: string | null;
ino: bigint;
sizeBytes: number | null;
mtime: Date | null;
@@ -291,6 +292,7 @@ export class ScannerService implements OnApplicationBootstrap {
id: number;
bookId: number;
absolutePath: string;
relPath: string | null;
ino: bigint;
sizeBytes: number | null;
mtime: Date | null;
@@ -316,7 +318,16 @@ export class ScannerService implements OnApplicationBootstrap {
const fileByPath = new Map<string, FileByPathEntry>(
knownFiles.map((f) => [
f.absolutePath,
{ id: f.id, bookId: f.bookId, ino: f.ino, sizeBytes: f.sizeBytes, mtime: f.mtime, fileHash: f.fileHash, sortOrder: f.sortOrder ?? null },
{
id: f.id,
bookId: f.bookId,
relPath: f.relPath,
ino: f.ino,
sizeBytes: f.sizeBytes,
mtime: f.mtime,
fileHash: f.fileHash,
sortOrder: f.sortOrder ?? null,
},
]),
);
@@ -956,10 +967,12 @@ export class ScannerService implements OnApplicationBootstrap {
id: f.id,
bookId: f.bookId,
absolutePath: f.absolutePath,
relPath: f.relPath,
ino: f.ino,
sizeBytes: f.sizeBytes,
mtime: f.mtime,
fileHash: f.fileHash,
sortOrder: f.sortOrder,
})),
);
}
@@ -2154,19 +2167,21 @@ export class ScannerService implements OnApplicationBootstrap {
const sizeUnchanged = fileStat.sizeBytes === byPath.sizeBytes;
const mtimeUnchanged = fileStat.mtime.getTime() === byPath.mtime?.getTime();
const inoUnchanged = fileStat.ino === byPath.ino;
const relPathUnchanged = fileStat.relPath === byPath.relPath;
const reassigned = byPath.bookId !== bookId;
const sortOrderUnchanged = sortOrder === byPath.sortOrder;
if (sizeUnchanged && mtimeUnchanged && inoUnchanged && !reassigned && sortOrderUnchanged) {
if (sizeUnchanged && mtimeUnchanged && inoUnchanged && relPathUnchanged && !reassigned && sortOrderUnchanged) {
return { isNew: false, reassigned: false, changed: false, fileId: byPath.id };
}
await waitForStability(fileStat.absolutePath, fileStat.mtime.getTime());
if (!sizeUnchanged || !mtimeUnchanged || !inoUnchanged || reassigned) {
if (!sizeUnchanged || !mtimeUnchanged || !inoUnchanged || !relPathUnchanged || reassigned) {
await this.scannerRepo.updateBookFile(byPath.id, {
...(reassigned && { bookId }),
libraryFolderId,
relPath: fileStat.relPath,
ino: fileStat.ino,
sizeBytes: fileStat.sizeBytes,
mtime: fileStat.mtime,
@@ -2187,6 +2202,7 @@ export class ScannerService implements OnApplicationBootstrap {
fileByPath.set(fileStat.absolutePath, {
id: byPath.id,
bookId,
relPath: fileStat.relPath,
ino: fileStat.ino,
sizeBytes: fileStat.sizeBytes,
mtime: fileStat.mtime,
@@ -2242,6 +2258,7 @@ export class ScannerService implements OnApplicationBootstrap {
fileByPath.set(fileStat.absolutePath, {
id: byIno.id,
bookId,
relPath: fileStat.relPath,
ino: fileStat.ino,
sizeBytes: fileStat.sizeBytes,
mtime: fileStat.mtime,
@@ -2308,6 +2325,7 @@ export class ScannerService implements OnApplicationBootstrap {
fileByPath.set(fileStat.absolutePath, {
id: globalByIno.file.id,
bookId,
relPath: fileStat.relPath,
ino: fileStat.ino,
sizeBytes: fileStat.sizeBytes,
mtime: fileStat.mtime,
@@ -2383,6 +2401,7 @@ export class ScannerService implements OnApplicationBootstrap {
fileByPath.set(fileStat.absolutePath, {
id: byHash.id,
bookId,
relPath: fileStat.relPath,
ino: fileStat.ino,
sizeBytes: fileStat.sizeBytes,
mtime: fileStat.mtime,
@@ -2443,6 +2462,7 @@ export class ScannerService implements OnApplicationBootstrap {
fileByPath.set(fileStat.absolutePath, {
id: globalByHash.file.id,
bookId,
relPath: fileStat.relPath,
ino: fileStat.ino,
sizeBytes: fileStat.sizeBytes,
mtime: fileStat.mtime,
@@ -2485,6 +2505,7 @@ export class ScannerService implements OnApplicationBootstrap {
{
id: concurrent.file.id,
bookId: concurrent.file.bookId,
relPath: concurrent.file.relPath,
ino: concurrent.file.ino,
sizeBytes: concurrent.file.sizeBytes,
mtime: concurrent.file.mtime,
@@ -2506,6 +2527,7 @@ export class ScannerService implements OnApplicationBootstrap {
fileByPath.set(fileStat.absolutePath, {
id: created.id,
bookId,
relPath: fileStat.relPath,
ino: fileStat.ino,
sizeBytes: fileStat.sizeBytes,
mtime: fileStat.mtime,