From 836186159a50b3a382230a54a374d7186d2806a4 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:26:29 +0000 Subject: [PATCH] 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 --- .../src/modules/book/book.repository.test.ts | 33 ++++++++++++++++- server/src/modules/book/book.repository.ts | 8 +++- server/src/modules/book/book.service.test.ts | 37 ++++++++++++++++--- server/src/modules/book/book.service.ts | 3 +- .../email-send-orchestrator.service.test.ts | 24 +++++++++--- .../email/email-send-orchestrator.service.ts | 10 +---- .../file-event-processor.service.test.ts | 27 +++++++++++++- .../scanner/file-event-processor.service.ts | 11 ++++-- .../scanner/scanner.repository.test.ts | 35 +++++++++++++++--- .../src/modules/scanner/scanner.repository.ts | 4 +- .../modules/scanner/scanner.service.test.ts | 32 ++++++++++++++++ server/src/modules/scanner/scanner.service.ts | 28 ++++++++++++-- 12 files changed, 217 insertions(+), 35 deletions(-) diff --git a/server/src/modules/book/book.repository.test.ts b/server/src/modules/book/book.repository.test.ts index 06e26920a..a626dca1f 100644 --- a/server/src/modules/book/book.repository.test.ts +++ b/server/src/modules/book/book.repository.test.ts @@ -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) => 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, diff --git a/server/src/modules/book/book.repository.ts b/server/src/modules/book/book.repository.ts index e52540142..6585c5d11 100644 --- a/server/src/modules/book/book.repository.ts +++ b/server/src/modules/book/book.repository.ts @@ -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 { + async updateBookFile( + fileId: number, + data: { format?: string | null; role?: string; absolutePath?: string; relPath?: string | null; sizeBytes?: number }, + ): Promise { await this.db .update(bookFiles) .set({ ...data, updatedAt: new Date() }) diff --git a/server/src/modules/book/book.service.test.ts b/server/src/modules/book/book.service.test.ts index c749b90f8..3241d0126 100644 --- a/server/src/modules/book/book.service.test.ts +++ b/server/src/modules/book/book.service.test.ts @@ -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, }); }); }); diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index bdfdf7c34..3772e77da 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -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`); diff --git a/server/src/modules/email/email-send-orchestrator.service.test.ts b/server/src/modules/email/email-send-orchestrator.service.test.ts index c7d413cf0..b286abc28 100644 --- a/server/src/modules/email/email-send-orchestrator.service.test.ts +++ b/server/src/modules/email/email-send-orchestrator.service.test.ts @@ -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'); }); diff --git a/server/src/modules/email/email-send-orchestrator.service.ts b/server/src/modules/email/email-send-orchestrator.service.ts index b8b3a65f9..39049d5dd 100644 --- a/server/src/modules/email/email-send-orchestrator.service.ts +++ b/server/src/modules/email/email-send-orchestrator.service.ts @@ -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 { diff --git a/server/src/modules/scanner/file-event-processor.service.test.ts b/server/src/modules/scanner/file-event-processor.service.test.ts index 91502cfe4..14f31b22f 100644 --- a/server/src/modules/scanner/file-event-processor.service.test.ts +++ b/server/src/modules/scanner/file-event-processor.service.test.ts @@ -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); diff --git a/server/src/modules/scanner/file-event-processor.service.ts b/server/src/modules/scanner/file-event-processor.service.ts index 8e11db19b..17cba8c75 100644 --- a/server/src/modules/scanner/file-event-processor.service.ts +++ b/server/src/modules/scanner/file-event-processor.service.ts @@ -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( diff --git a/server/src/modules/scanner/scanner.repository.test.ts b/server/src/modules/scanner/scanner.repository.test.ts index 8ac944928..2c6b05e71 100644 --- a/server/src/modules/scanner/scanner.repository.test.ts +++ b/server/src/modules/scanner/scanner.repository.test.ts @@ -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' }); diff --git a/server/src/modules/scanner/scanner.repository.ts b/server/src/modules/scanner/scanner.repository.ts index e58469ec1..2913909d2 100644 --- a/server/src/modules/scanner/scanner.repository.ts +++ b/server/src/modules/scanner/scanner.repository.ts @@ -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; diff --git a/server/src/modules/scanner/scanner.service.test.ts b/server/src/modules/scanner/scanner.service.test.ts index 1b58ae9c8..0bba5b9b5 100644 --- a/server/src/modules/scanner/scanner.service.test.ts +++ b/server/src/modules/scanner/scanner.service.test.ts @@ -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; diff --git a/server/src/modules/scanner/scanner.service.ts b/server/src/modules/scanner/scanner.service.ts index 8213be3e5..a313f09b4 100644 --- a/server/src/modules/scanner/scanner.service.ts +++ b/server/src/modules/scanner/scanner.service.ts @@ -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( 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,