mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user