fix: preserve book file rows on delete so restored files keep their IDs

This commit is contained in:
neon
2026-01-28 12:47:51 -06:00
parent 426bc5fc82
commit 325d0f6f0f
15 changed files with 581 additions and 200 deletions
@@ -94,29 +94,3 @@ describe('onBookMissing', () => {
})
})
// ── onBookFileRemoved ──────────────────────────────────────────────────────────
describe('onBookFileRemoved', () => {
it('fires the callback with bookId and fileId on book:file:removed event', () => {
const { onBookFileRemoved } = useBookEvents()
const cb = vi.fn()
const cleanup = onBookFileRemoved(cb)
mockSocket.emit('book:file:removed', { libraryId: 1, bookId: 42, fileId: 7 })
expect(cb).toHaveBeenCalledTimes(1)
expect(cb).toHaveBeenCalledWith(42, 7)
cleanup()
})
it('cleanup deregisters the callback', () => {
const { onBookFileRemoved } = useBookEvents()
const cb = vi.fn()
const cleanup = onBookFileRemoved(cb)
cleanup()
mockSocket.emit('book:file:removed', { libraryId: 1, bookId: 1, fileId: 1 })
expect(cb).not.toHaveBeenCalled()
})
})
@@ -1,12 +1,12 @@
import { toast } from 'vue-sonner'
import type { BookFileRemovedEvent, BookMissingEvent } from '@projectx/types'
import type { BookMissingEvent, BookRestoredEvent } from '@projectx/types'
import { getSocket } from '@/features/scanner/composables/useScanProgress'
type BookMissingCallback = (bookIds: number[]) => void
type BookFileRemovedCallback = (bookId: number, fileId: number) => void
type BookRestoredCallback = (bookIds: number[]) => void
const missingCallbacks = new Set<BookMissingCallback>()
const fileRemovedCallbacks = new Set<BookFileRemovedCallback>()
const restoredCallbacks = new Set<BookRestoredCallback>()
let pendingMissingCount = 0
let toastTimer: ReturnType<typeof setTimeout> | null = null
@@ -34,8 +34,8 @@ function ensureInitialized() {
toastTimer = setTimeout(flushMissingToast, 1000)
})
socket.on('book:file:removed', (event: BookFileRemovedEvent) => {
for (const cb of fileRemovedCallbacks) cb(event.bookId, event.fileId)
socket.on('book:restored', (event: BookRestoredEvent) => {
for (const cb of restoredCallbacks) cb(event.bookIds)
})
}
@@ -47,10 +47,10 @@ export function useBookEvents() {
return () => missingCallbacks.delete(cb)
}
function onBookFileRemoved(cb: BookFileRemovedCallback): () => void {
fileRemovedCallbacks.add(cb)
return () => fileRemovedCallbacks.delete(cb)
function onBookRestored(cb: BookRestoredCallback): () => void {
restoredCallbacks.add(cb)
return () => restoredCallbacks.delete(cb)
}
return { onBookMissing, onBookFileRemoved }
return { onBookMissing, onBookRestored }
}
+8 -4
View File
@@ -11,6 +11,7 @@ import FilesTab from '@/features/book/components/detail/tabs/FilesTab.vue'
import { useBookDetail } from '@/features/book/composables/useBookDetail'
import { useBookEvents } from '@/features/book/composables/useBookEvents'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
const route = useRoute()
const router = useRouter()
@@ -21,14 +22,17 @@ const activeTab = computed(() => (route.query.tab as string) || 'details')
const { detail, loading, fetch } = useBookDetail()
const { libraries, fetchLibraries } = useLibraries()
const { onBookMissing, onBookFileRemoved } = useBookEvents()
const { subscribeLibrary } = useScanProgress()
watch(() => detail.value?.libraryId, (id) => { if (id !== undefined) subscribeLibrary(id) })
const { onBookMissing, onBookRestored } = useBookEvents()
onBookMissing((bookIds) => {
if (detail.value && bookIds.includes(detail.value.id)) {
detail.value = { ...detail.value, status: 'missing', files: [] }
detail.value = { ...detail.value, status: 'missing' }
}
})
onBookFileRemoved((bookId) => {
if (detail.value?.id === bookId) fetch(bookId)
onBookRestored((bookIds) => {
if (detail.value && bookIds.includes(detail.value.id)) fetch(detail.value.id)
})
watch(bookId, (id) => fetch(id), { immediate: true })
+11 -1
View File
@@ -14,6 +14,7 @@ import { useBookQuery, type BookCard } from '@/features/book/composables/useBook
import { useBookEvents } from '@/features/book/composables/useBookEvents'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
import type { SortField } from '@projectx/types'
@@ -30,13 +31,22 @@ const title = computed(() => libraries.value.find((l) => l.id === libraryId.valu
const { items: books, total, loading, error, filter, sort, hasMore, load, clear } = useBookQuery(libraryId)
const { onBookMissing } = useBookEvents()
const { subscribeLibrary } = useScanProgress()
watch(libraryId, (id) => { if (id !== null) subscribeLibrary(id) }, { immediate: true })
const { onBookMissing, onBookRestored } = useBookEvents()
onBookMissing((bookIds) => {
const missing = new Set(bookIds)
for (const book of books.value) {
if (missing.has(book.id)) book.status = 'missing'
}
})
onBookRestored((bookIds) => {
const restored = new Set(bookIds)
for (const book of books.value) {
if (restored.has(book.id)) book.status = 'present'
}
})
const filterOpen = ref(false)
+1 -1
View File
@@ -18,6 +18,6 @@
"concurrently": "^9.2.1"
},
"pnpm": {
"onlyBuiltDependencies": ["@nestjs/core", "@swc/core", "esbuild", "sharp", "unrs-resolver"]
"onlyBuiltDependencies": ["@nestjs/core", "@parcel/watcher", "@swc/core", "esbuild", "sharp", "unrs-resolver"]
}
}
+2 -3
View File
@@ -27,8 +27,7 @@ export interface BookMissingEvent {
bookIds: number[];
}
export interface BookFileRemovedEvent {
export interface BookRestoredEvent {
libraryId: number;
bookId: number;
fileId: number;
bookIds: number[];
}
+1 -1
View File
@@ -39,9 +39,9 @@
"@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/schedule": "^6.1.0",
"@nestjs/websockets": "^11.1.12",
"@parcel/watcher": "^2.5.6",
"@projectx/types": "workspace:*",
"bcryptjs": "^3.0.3",
"chokidar": "5.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"drizzle-orm": "^0.45.1",
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1769655184878,
"when": 1769672547955,
"tag": "0000_baseline",
"breakpoints": true
}
@@ -1,34 +1,61 @@
import { FileEventProcessorService } from './file-event-processor.service';
import { ScannerRepository } from './scanner.repository';
jest.mock('fs/promises', () => ({
stat: jest.fn(),
}));
import { stat } from 'fs/promises';
const mockStat = stat as jest.MockedFunction<typeof stat>;
const mockRepo: jest.Mocked<
Pick<
ScannerRepository,
| 'findBookFileByAbsolutePath'
| 'deleteBookFile'
| 'countBookFilesByBookId'
| 'markBooksAsMissing'
| 'findBooksByFolderPath'
| 'deleteBookFilesByBookIds'
| 'findMissingBookByFolderPath'
| 'findMissingBooksByFolderPath'
| 'findMissingBooksForLibraries'
| 'findPrimaryBookFilesByBookId'
| 'createBookFile'
| 'updateBookFile'
| 'markBooksAsPresent'
>
> = {
findBookFileByAbsolutePath: jest.fn(),
deleteBookFile: jest.fn(),
countBookFilesByBookId: jest.fn(),
markBooksAsMissing: jest.fn(),
findBooksByFolderPath: jest.fn(),
deleteBookFilesByBookIds: jest.fn(),
findMissingBookByFolderPath: jest.fn(),
findMissingBooksByFolderPath: jest.fn(),
findMissingBooksForLibraries: jest.fn(),
findPrimaryBookFilesByBookId: jest.fn(),
createBookFile: jest.fn(),
updateBookFile: jest.fn(),
markBooksAsPresent: jest.fn(),
};
function makeService() {
return new FileEventProcessorService(mockRepo as unknown as ScannerRepository);
}
function makeFileStat(overrides: Partial<{ ino: number; size: number; mtime: Date; isDirectory: boolean; isFile: boolean }> = {}) {
return {
ino: overrides.ino ?? 1001,
size: overrides.size ?? 12345,
mtime: overrides.mtime ?? new Date('2024-01-01'),
isDirectory: () => overrides.isDirectory ?? false,
isFile: () => overrides.isFile ?? true,
} as any;
}
beforeEach(() => {
jest.clearAllMocks();
mockRepo.deleteBookFile.mockResolvedValue(undefined);
mockRepo.markBooksAsMissing.mockResolvedValue(undefined);
mockRepo.deleteBookFilesByBookIds.mockResolvedValue(undefined);
mockRepo.markBooksAsPresent.mockResolvedValue(undefined);
mockRepo.createBookFile.mockResolvedValue({} as any);
mockRepo.updateBookFile.mockResolvedValue({} as any);
});
// ── handleUnlink ──────────────────────────────────────────────────────────────
@@ -40,33 +67,17 @@ describe('handleUnlink', () => {
const result = await makeService().handleUnlink('/untracked/file.epub');
expect(result).toEqual({ type: 'noop' });
expect(mockRepo.deleteBookFile).not.toHaveBeenCalled();
});
it('returns file-removed when other files remain for the book', async () => {
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
file: { id: 42, bookId: 7 },
libraryId: 3,
} as any);
mockRepo.countBookFilesByBookId.mockResolvedValue(2); // 2 files remain after deletion
const result = await makeService().handleUnlink('/books/Author/book.mobi');
expect(mockRepo.deleteBookFile).toHaveBeenCalledWith(42);
expect(mockRepo.markBooksAsMissing).not.toHaveBeenCalled();
expect(result).toEqual({ type: 'file-removed', libraryId: 3, bookId: 7, fileId: 42 });
});
it('returns book-missing and marks book when last file is deleted', async () => {
it('marks book missing and preserves file row', async () => {
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
file: { id: 99, bookId: 12 },
libraryId: 5,
} as any);
mockRepo.countBookFilesByBookId.mockResolvedValue(0);
const result = await makeService().handleUnlink('/books/Solo/solo.epub');
expect(mockRepo.deleteBookFile).toHaveBeenCalledWith(99);
expect(mockRepo.markBooksAsMissing).toHaveBeenCalledWith([12]);
expect(result).toEqual({ type: 'book-missing', libraryId: 5, bookIds: [12] });
});
@@ -81,30 +92,205 @@ describe('handleUnlinkDir', () => {
const result = await makeService().handleUnlinkDir('/empty/folder');
expect(result).toEqual({ type: 'noop' });
expect(mockRepo.deleteBookFilesByBookIds).not.toHaveBeenCalled();
expect(mockRepo.markBooksAsMissing).not.toHaveBeenCalled();
});
it('returns book-missing for a single-book folder', async () => {
mockRepo.findBooksByFolderPath.mockResolvedValue([{ id: 20, libraryId: 1 }] as any);
const result = await makeService().handleUnlinkDir('/books/Author/One Book');
expect(mockRepo.deleteBookFilesByBookIds).toHaveBeenCalledWith([20]);
expect(mockRepo.markBooksAsMissing).toHaveBeenCalledWith([20]);
expect(result).toEqual({ type: 'book-missing', libraryId: 1, bookIds: [20] });
});
it('returns book-missing for all books under a parent folder', async () => {
it('marks books missing without deleting file rows', async () => {
mockRepo.findBooksByFolderPath.mockResolvedValue([
{ id: 30, libraryId: 2 },
{ id: 31, libraryId: 2 },
{ id: 20, libraryId: 1 },
{ id: 21, libraryId: 1 },
] as any);
const result = await makeService().handleUnlinkDir('/books/Series');
const result = await makeService().handleUnlinkDir('/books/Author');
expect(mockRepo.deleteBookFilesByBookIds).toHaveBeenCalledWith([30, 31]);
expect(mockRepo.markBooksAsMissing).toHaveBeenCalledWith([30, 31]);
expect(result).toEqual({ type: 'book-missing', libraryId: 2, bookIds: [30, 31] });
expect(mockRepo.markBooksAsMissing).toHaveBeenCalledWith([20, 21]);
expect(result).toEqual({ type: 'book-missing', libraryId: 1, bookIds: [20, 21] });
});
});
// ── handleCreate (file) ───────────────────────────────────────────────────────
describe('handleCreate — file', () => {
it('returns noop when stat fails (path no longer exists)', async () => {
mockStat.mockRejectedValue(new Error('ENOENT'));
const result = await makeService().handleCreate('/books/Author/ghost.epub');
expect(result).toEqual({ type: 'noop' });
});
it('returns noop for non-primary file formats', async () => {
mockStat.mockResolvedValue(makeFileStat());
const result = await makeService().handleCreate('/books/Author/cover.jpg');
expect(result).toEqual({ type: 'noop' });
});
it('returns noop when file row exists but book is not missing', async () => {
mockStat.mockResolvedValue(makeFileStat());
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({ file: { id: 1 }, libraryId: 1 } as any);
mockRepo.findMissingBookByFolderPath.mockResolvedValue(null);
const result = await makeService().handleCreate('/books/Author/book.epub');
expect(result).toEqual({ type: 'noop' });
});
it('restores book by updating existing file row when book is missing', async () => {
const fileStat = makeFileStat({ ino: 2000, size: 50000 });
mockStat.mockResolvedValue(fileStat);
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
file: { id: 42, bookId: 10 },
libraryId: 3,
} as any);
mockRepo.findMissingBookByFolderPath.mockResolvedValue({
id: 10, libraryId: 3, libraryFolderId: 5,
} as any);
const result = await makeService().handleCreate('/books/Author/book.epub');
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(42, expect.objectContaining({ ino: BigInt(2000), sizeBytes: BigInt(50000) }));
expect(mockRepo.markBooksAsPresent).toHaveBeenCalledWith([10]);
expect(mockRepo.createBookFile).not.toHaveBeenCalled();
expect(result).toEqual({ type: 'book-restored', libraryId: 3, bookIds: [10] });
});
it('returns noop when no file row and no missing book', async () => {
mockStat.mockResolvedValue(makeFileStat());
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
mockRepo.findMissingBookByFolderPath.mockResolvedValue(null);
const result = await makeService().handleCreate('/books/Author/book.epub');
expect(result).toEqual({ type: 'noop' });
expect(mockRepo.createBookFile).not.toHaveBeenCalled();
});
it('creates new file row when no existing row but missing book found via dirname', async () => {
const fileStat = makeFileStat({ ino: 2000, size: 50000 });
mockStat.mockResolvedValue(fileStat);
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
mockRepo.findMissingBookByFolderPath.mockResolvedValue({
id: 55, libraryId: 4, libraryFolderId: 10,
} as any);
const result = await makeService().handleCreate('/books/Author/book.epub');
expect(mockRepo.createBookFile).toHaveBeenCalledWith(
expect.objectContaining({ bookId: 55, absolutePath: '/books/Author/book.epub', format: 'epub', role: 'primary' }),
);
expect(mockRepo.markBooksAsPresent).toHaveBeenCalledWith([55]);
expect(result).toEqual({ type: 'book-restored', libraryId: 4, bookIds: [55] });
});
it('creates new file row for root-level book where folderPath equals file path', async () => {
const fileStat = makeFileStat({ ino: 3000, size: 60000 });
mockStat.mockResolvedValue(fileStat);
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
mockRepo.findMissingBookByFolderPath
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 60, libraryId: 5, libraryFolderId: 15 } as any);
const result = await makeService().handleCreate('/books/Audiobooks/book.pdf');
expect(mockRepo.findMissingBookByFolderPath).toHaveBeenCalledWith('/books/Audiobooks');
expect(mockRepo.findMissingBookByFolderPath).toHaveBeenCalledWith('/books/Audiobooks/book.pdf');
expect(mockRepo.createBookFile).toHaveBeenCalledWith(
expect.objectContaining({ bookId: 60, absolutePath: '/books/Audiobooks/book.pdf', format: 'pdf', role: 'primary' }),
);
expect(mockRepo.markBooksAsPresent).toHaveBeenCalledWith([60]);
expect(result).toEqual({ type: 'book-restored', libraryId: 5, bookIds: [60] });
});
});
// ── handleCreate (directory / folder restoration) ────────────────────────────
describe('handleCreate — directory (folder restoration)', () => {
it('returns noop when no missing books match the folder', async () => {
mockStat.mockResolvedValue(makeFileStat({ isDirectory: true, isFile: false }));
mockRepo.findMissingBooksByFolderPath.mockResolvedValue([]);
const result = await makeService().handleCreate('/books/Author');
expect(result).toEqual({ type: 'noop' });
});
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 }));
mockRepo.findMissingBooksByFolderPath.mockResolvedValue([
{ id: 70, libraryId: 6, libraryFolderId: 20, folderPath: '/books/Author/Title' } as any,
]);
mockRepo.findPrimaryBookFilesByBookId.mockResolvedValue([
{ id: 100, absolutePath: '/books/Author/Title/book.epub', bookId: 70 } as any,
]);
const result = await makeService().handleCreate('/books/Author/Title');
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(100, expect.objectContaining({ ino: BigInt(3000) }));
expect(mockRepo.markBooksAsPresent).toHaveBeenCalledWith([70]);
expect(result).toEqual({ type: 'book-restored', libraryId: 6, bookIds: [70] });
});
it('returns noop when files for missing books no longer exist on disk', async () => {
mockStat
.mockResolvedValueOnce(makeFileStat({ isDirectory: true, isFile: false }))
.mockRejectedValueOnce(new Error('ENOENT'));
mockRepo.findMissingBooksByFolderPath.mockResolvedValue([
{ id: 80, libraryId: 7, libraryFolderId: 30, folderPath: '/books/gone' } as any,
]);
mockRepo.findPrimaryBookFilesByBookId.mockResolvedValue([
{ id: 200, absolutePath: '/books/gone/book.epub', bookId: 80 } as any,
]);
const result = await makeService().handleCreate('/books/gone');
expect(mockRepo.markBooksAsPresent).not.toHaveBeenCalled();
expect(result).toEqual({ type: 'noop' });
});
});
// ── reconcileMissingBooks ────────────────────────────────────────────────────
describe('reconcileMissingBooks', () => {
it('returns empty when no missing books', async () => {
mockRepo.findMissingBooksForLibraries.mockResolvedValue([]);
const results = await makeService().reconcileMissingBooks([1]);
expect(results).toEqual([]);
});
it('restores books whose files reappear on disk', async () => {
mockRepo.findMissingBooksForLibraries.mockResolvedValue([
{ id: 50, libraryId: 2, libraryFolderId: 10, folderPath: '/books/restored' } as any,
]);
mockRepo.findPrimaryBookFilesByBookId.mockResolvedValue([
{ id: 300, absolutePath: '/books/restored/book.epub', bookId: 50 } as any,
]);
mockStat.mockResolvedValue(makeFileStat({ ino: 5000, size: 99000 }));
const results = await makeService().reconcileMissingBooks([2]);
expect(mockRepo.updateBookFile).toHaveBeenCalledWith(300, expect.objectContaining({ ino: BigInt(5000) }));
expect(mockRepo.markBooksAsPresent).toHaveBeenCalledWith([50]);
expect(results).toEqual([{ type: 'book-restored', libraryId: 2, bookIds: [50] }]);
});
it('skips books whose files are still gone', async () => {
mockRepo.findMissingBooksForLibraries.mockResolvedValue([
{ id: 51, libraryId: 2, libraryFolderId: 10, folderPath: '/books/still-gone' } as any,
]);
mockRepo.findPrimaryBookFilesByBookId.mockResolvedValue([
{ id: 301, absolutePath: '/books/still-gone/book.epub', bookId: 51 } as any,
]);
mockStat.mockRejectedValue(new Error('ENOENT'));
const results = await makeService().reconcileMissingBooks([2]);
expect(mockRepo.markBooksAsPresent).not.toHaveBeenCalled();
expect(results).toEqual([]);
});
});
@@ -1,10 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { stat } from 'fs/promises';
import { dirname } from 'path';
import { classifyFile } from './lib/classify';
import { ScannerRepository } from './scanner.repository';
export type FileEventResult =
| { type: 'book-missing'; libraryId: number; bookIds: number[] }
| { type: 'file-removed'; libraryId: number; bookId: number; fileId: number }
| { type: 'book-restored'; libraryId: number; bookIds: number[] }
| { type: 'noop' };
@Injectable()
@@ -18,19 +21,11 @@ export class FileEventProcessorService {
if (!row) return { type: 'noop' };
const { file, libraryId } = row;
await this.scannerRepo.deleteBookFile(file.id);
const remaining = await this.scannerRepo.countBookFilesByBookId(file.bookId);
if (remaining === 0) {
await this.scannerRepo.markBooksAsMissing([file.bookId]);
this.logger.log(`Book ${file.bookId} marked missing — last file removed: ${absolutePath}`);
this.logger.log(`Book ${file.bookId} marked missing — file removed: ${absolutePath}`);
return { type: 'book-missing', libraryId, bookIds: [file.bookId] };
}
this.logger.log(`File ${file.id} removed from book ${file.bookId}: ${absolutePath}`);
return { type: 'file-removed', libraryId, bookId: file.bookId, fileId: file.id };
}
async handleUnlinkDir(absolutePath: string): Promise<FileEventResult> {
const matched = await this.scannerRepo.findBooksByFolderPath(absolutePath);
if (matched.length === 0) return { type: 'noop' };
@@ -38,10 +33,113 @@ export class FileEventProcessorService {
const bookIds = matched.map((b) => b.id);
const libraryId = matched[0]!.libraryId;
await this.scannerRepo.deleteBookFilesByBookIds(bookIds);
await this.scannerRepo.markBooksAsMissing(bookIds);
this.logger.log(`${bookIds.length} book(s) marked missing — folder removed: ${absolutePath}`);
return { type: 'book-missing', libraryId, bookIds };
}
async handleCreate(absolutePath: string): Promise<FileEventResult> {
const fileStat = await stat(absolutePath).catch(() => null);
if (!fileStat) return { type: 'noop' };
if (fileStat.isDirectory()) return this.handleCreateDir(absolutePath);
const { role, format } = classifyFile(absolutePath);
if (role !== 'primary') return { type: 'noop' };
const existing = await this.scannerRepo.findBookFileByAbsolutePath(absolutePath);
if (existing) {
const book = await this.scannerRepo.findMissingBookByFolderPath(dirname(absolutePath)) ??
await this.scannerRepo.findMissingBookByFolderPath(absolutePath);
if (!book) return { type: 'noop' };
await this.scannerRepo.updateBookFile(existing.file.id, {
ino: BigInt(fileStat.ino),
sizeBytes: BigInt(fileStat.size),
mtime: fileStat.mtime,
});
await this.scannerRepo.markBooksAsPresent([book.id]);
this.logger.log(`Book ${book.id} restored — file returned: ${absolutePath}`);
return { type: 'book-restored', libraryId: book.libraryId, bookIds: [book.id] };
}
const folderPath = dirname(absolutePath);
const book =
(await this.scannerRepo.findMissingBookByFolderPath(folderPath)) ??
(await this.scannerRepo.findMissingBookByFolderPath(absolutePath));
if (!book) return { type: 'noop' };
await this.scannerRepo.createBookFile({
bookId: book.id,
libraryFolderId: book.libraryFolderId,
absolutePath,
ino: BigInt(fileStat.ino),
sizeBytes: BigInt(fileStat.size),
mtime: fileStat.mtime,
format,
role: 'primary',
});
await this.scannerRepo.markBooksAsPresent([book.id]);
this.logger.log(`Book ${book.id} restored — file returned: ${absolutePath}`);
return { type: 'book-restored', libraryId: book.libraryId, bookIds: [book.id] };
}
async reconcileMissingBooks(libraryIds: number[]): Promise<FileEventResult[]> {
const missing = await this.scannerRepo.findMissingBooksForLibraries(libraryIds);
if (missing.length === 0) return [];
const results: FileEventResult[] = [];
for (const book of missing) {
const result = await this.tryRestoreBook(book);
if (result.type !== 'noop') results.push(result);
}
return results;
}
private async tryRestoreBook(book: {
id: number;
libraryId: number;
libraryFolderId: number;
folderPath: string;
}): Promise<FileEventResult> {
const files = await this.scannerRepo.findPrimaryBookFilesByBookId(book.id);
for (const file of files) {
const s = await stat(file.absolutePath).catch(() => null);
if (!s || !s.isFile()) continue;
await this.scannerRepo.updateBookFile(file.id, {
ino: BigInt(s.ino),
sizeBytes: BigInt(s.size),
mtime: s.mtime,
});
await this.scannerRepo.markBooksAsPresent([book.id]);
this.logger.log(`Book ${book.id} restored — file returned: ${file.absolutePath}`);
return { type: 'book-restored', libraryId: book.libraryId, bookIds: [book.id] };
}
return { type: 'noop' };
}
private async handleCreateDir(absolutePath: string): Promise<FileEventResult> {
const missingBooks = await this.scannerRepo.findMissingBooksByFolderPath(absolutePath);
if (missingBooks.length === 0) return { type: 'noop' };
const restoredIds: number[] = [];
const libraryId = missingBooks[0]!.libraryId;
for (const book of missingBooks) {
const result = await this.tryRestoreBook(book);
if (result.type !== 'noop') restoredIds.push(book.id);
}
if (restoredIds.length === 0) return { type: 'noop' };
this.logger.log(`${restoredIds.length} book(s) restored: folder returned: ${absolutePath}`);
return { type: 'book-restored', libraryId, bookIds: restoredIds };
}
}
@@ -2,20 +2,20 @@ import { FileWatcherService } from './file-watcher.service';
import { FileEventProcessorService } from './file-event-processor.service';
import { ScanGateway } from './scan.gateway';
function makeService(processorResult: any = { type: 'noop' }) {
function makeService() {
const processor = {
handleUnlink: jest.fn().mockResolvedValue(processorResult),
handleUnlinkDir: jest.fn().mockResolvedValue(processorResult),
handleUnlink: jest.fn().mockResolvedValue({ type: 'noop' }),
handleUnlinkDir: jest.fn().mockResolvedValue({ type: 'noop' }),
handleCreate: jest.fn().mockResolvedValue({ type: 'noop' }),
reconcileMissingBooks: jest.fn().mockResolvedValue([]),
} as unknown as FileEventProcessorService;
const gateway = {
emitBookMissing: jest.fn(),
emitBookFileRemoved: jest.fn(),
emitBookRestored: jest.fn(),
} as unknown as ScanGateway;
// DB stub — onApplicationBootstrap queries libraries; tests don't call it so db is unused
const db = {} as any;
const service = new FileWatcherService(db, processor, gateway);
return { service, processor, gateway };
}
@@ -26,49 +26,48 @@ afterEach(() => jest.useRealTimers());
// ── process() routing ─────────────────────────────────────────────────────────
describe('process()', () => {
it('calls emitBookMissing when processor returns book-missing', async () => {
it('emits book-missing when handleUnlink returns book-missing', async () => {
const { service, processor, gateway } = makeService();
const missing = { type: 'book-missing', libraryId: 1, bookIds: [10, 11] };
const { service, processor, gateway } = makeService(missing);
processor.handleUnlink = jest.fn().mockResolvedValue(missing);
await (service as any).process('unlink', '/books/Author/book.epub');
await (service as any).process('delete', '/books/Author/book.epub');
expect(gateway.emitBookMissing).toHaveBeenCalledWith({ libraryId: 1, bookIds: [10, 11] });
expect(gateway.emitBookFileRemoved).not.toHaveBeenCalled();
});
it('calls emitBookFileRemoved when processor returns file-removed', async () => {
const removed = { type: 'file-removed', libraryId: 2, bookId: 5, fileId: 99 };
const { service, processor, gateway } = makeService(removed);
processor.handleUnlink = jest.fn().mockResolvedValue(removed);
await (service as any).process('unlink', '/books/Author/book.pdf');
expect(gateway.emitBookFileRemoved).toHaveBeenCalledWith({ libraryId: 2, bookId: 5, fileId: 99 });
expect(gateway.emitBookMissing).not.toHaveBeenCalled();
});
it('calls neither gateway method when processor returns noop', async () => {
const { service, processor, gateway } = makeService({ type: 'noop' });
processor.handleUnlink = jest.fn().mockResolvedValue({ type: 'noop' });
await (service as any).process('unlink', '/nowhere/file.epub');
expect(gateway.emitBookMissing).not.toHaveBeenCalled();
expect(gateway.emitBookFileRemoved).not.toHaveBeenCalled();
});
it('delegates unlinkDir to processor.handleUnlinkDir', async () => {
it('falls back to handleUnlinkDir when handleUnlink returns noop on delete', async () => {
const { service, processor, gateway } = makeService();
const missing = { type: 'book-missing', libraryId: 3, bookIds: [20] };
const { service, processor, gateway } = makeService(missing);
processor.handleUnlink = jest.fn().mockResolvedValue({ type: 'noop' });
processor.handleUnlinkDir = jest.fn().mockResolvedValue(missing);
await (service as any).process('unlinkDir', '/books/Author');
await (service as any).process('delete', '/books/Author');
expect(processor.handleUnlink).toHaveBeenCalledWith('/books/Author');
expect(processor.handleUnlinkDir).toHaveBeenCalledWith('/books/Author');
expect(processor.handleUnlink).not.toHaveBeenCalled();
expect(gateway.emitBookMissing).toHaveBeenCalledWith({ libraryId: 3, bookIds: [20] });
});
it('emits book-restored when handleCreate returns book-restored', async () => {
const { service, processor, gateway } = makeService();
const restored = { type: 'book-restored', libraryId: 1, bookIds: [7] };
processor.handleCreate = jest.fn().mockResolvedValue(restored);
await (service as any).process('create', '/books/Author/book.epub');
expect(processor.handleCreate).toHaveBeenCalledWith('/books/Author/book.epub');
expect((gateway as any).emitBookRestored).toHaveBeenCalledWith({ libraryId: 1, bookIds: [7] });
expect(gateway.emitBookMissing).not.toHaveBeenCalled();
});
it('emits nothing when both handlers return noop', async () => {
const { service, gateway } = makeService();
await (service as any).process('delete', '/nowhere/file.epub');
expect(gateway.emitBookMissing).not.toHaveBeenCalled();
});
});
// ── schedule() debounce ───────────────────────────────────────────────────────
@@ -78,23 +77,37 @@ describe('schedule() debounce', () => {
const { service } = makeService();
const processSpy = jest.spyOn(service as any, 'process').mockResolvedValue(undefined);
(service as any).schedule('unlink', '/books/file.epub');
(service as any).schedule('unlink', '/books/file.epub'); // cancels previous
(service as any).schedule('unlink', '/books/file.epub'); // cancels previous
(service as any).schedule('delete', '/books/file.epub');
(service as any).schedule('delete', '/books/file.epub');
(service as any).schedule('delete', '/books/file.epub');
jest.runAllTimers();
await Promise.resolve(); // flush microtasks
await Promise.resolve();
expect(processSpy).toHaveBeenCalledTimes(1);
expect(processSpy).toHaveBeenCalledWith('unlink', '/books/file.epub');
expect(processSpy).toHaveBeenCalledWith('delete', '/books/file.epub');
});
it('last event type wins when delete and create race for the same path', async () => {
const { service } = makeService();
const processSpy = jest.spyOn(service as any, 'process').mockResolvedValue(undefined);
(service as any).schedule('delete', '/books/file.epub');
(service as any).schedule('create', '/books/file.epub'); // overrides delete
jest.runAllTimers();
await Promise.resolve();
expect(processSpy).toHaveBeenCalledTimes(1);
expect(processSpy).toHaveBeenCalledWith('create', '/books/file.epub');
});
it('does not debounce events for different paths', async () => {
const { service } = makeService();
const processSpy = jest.spyOn(service as any, 'process').mockResolvedValue(undefined);
(service as any).schedule('unlink', '/books/file-a.epub');
(service as any).schedule('unlink', '/books/file-b.epub');
(service as any).schedule('delete', '/books/file-a.epub');
(service as any).schedule('delete', '/books/file-b.epub');
jest.runAllTimers();
await Promise.resolve();
@@ -102,3 +115,40 @@ describe('schedule() debounce', () => {
expect(processSpy).toHaveBeenCalledTimes(2);
});
});
// ── reconcile() ───────────────────────────────────────────────────────────────
describe('reconcile()', () => {
it('emits book-restored for each restored result from reconcileMissingBooks', async () => {
const { service, processor, gateway } = makeService();
(service as any).subscriptions.set(1, []);
(processor.reconcileMissingBooks as jest.Mock).mockResolvedValue([
{ type: 'book-restored', libraryId: 1, bookIds: [10, 11] },
{ type: 'book-restored', libraryId: 2, bookIds: [20] },
]);
await (service as any).reconcile();
expect(gateway.emitBookRestored).toHaveBeenCalledTimes(2);
expect(gateway.emitBookRestored).toHaveBeenCalledWith({ libraryId: 1, bookIds: [10, 11] });
expect(gateway.emitBookRestored).toHaveBeenCalledWith({ libraryId: 2, bookIds: [20] });
});
it('does nothing when reconcileMissingBooks returns empty', async () => {
const { service, processor, gateway } = makeService();
(service as any).subscriptions.set(1, []);
await (service as any).reconcile();
expect(gateway.emitBookRestored).not.toHaveBeenCalled();
});
it('does nothing when no libraries are being watched', async () => {
const { service, processor, gateway } = makeService();
await (service as any).reconcile();
expect(processor.reconcileMissingBooks).not.toHaveBeenCalled();
expect(gateway.emitBookRestored).not.toHaveBeenCalled();
});
});
@@ -1,7 +1,7 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { FSWatcher } from 'chokidar';
import type { AsyncSubscription } from '@parcel/watcher';
import { DB } from '../../db';
import * as schema from '../../db/schema';
@@ -10,14 +10,17 @@ import { ScanGateway } from './scan.gateway';
import { FileEventProcessorService } from './file-event-processor.service';
type Db = NodePgDatabase<typeof schema>;
type EventType = 'delete' | 'create';
const DEBOUNCE_MS = 500;
const RECONCILE_MS = 30_000;
@Injectable()
export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger(FileWatcherService.name);
private readonly watchers = new Map<number, FSWatcher>();
private readonly pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly subscriptions = new Map<number, AsyncSubscription[]>();
private readonly pendingTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; type: EventType }>();
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
constructor(
@Inject(DB) private readonly db: Db,
@@ -34,56 +37,92 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
folders.map((f) => f.path),
);
}
this.reconcileTimer = setInterval(() => {
this.reconcile().catch((err) => this.logger.error(`Reconcile error: ${(err as Error).message}`));
}, RECONCILE_MS);
}
async onModuleDestroy(): Promise<void> {
for (const timer of this.pendingTimers.values()) clearTimeout(timer);
if (this.reconcileTimer) clearInterval(this.reconcileTimer);
for (const entry of this.pendingTimers.values()) clearTimeout(entry.timer);
this.pendingTimers.clear();
for (const watcher of this.watchers.values()) await watcher.close();
this.watchers.clear();
for (const subs of this.subscriptions.values()) {
for (const sub of subs) await sub.unsubscribe();
}
this.subscriptions.clear();
}
private async reconcile(): Promise<void> {
const libraryIds = [...this.subscriptions.keys()];
if (libraryIds.length === 0) return;
const results = await this.processor.reconcileMissingBooks(libraryIds);
for (const result of results) {
if (result.type === 'book-restored') {
this.gateway.emitBookRestored({ libraryId: result.libraryId, bookIds: result.bookIds });
}
}
}
async startWatcher(libraryId: number, paths: string[]): Promise<void> {
await this.stopWatcher(libraryId);
if (paths.length === 0) return;
const { watch } = await import('chokidar');
const watcher = watch(paths, { ignoreInitial: true, persistent: true });
const { subscribe } = await import('@parcel/watcher');
const subs: AsyncSubscription[] = [];
watcher.on('unlink', (path) => this.schedule('unlink', path));
watcher.on('unlinkDir', (path) => this.schedule('unlinkDir', path));
watcher.on('error', (err) => this.logger.warn(`Watcher error for library ${libraryId}: ${err}`));
for (const path of paths) {
const sub = await subscribe(path, (err, events) => {
if (err) {
this.logger.warn(`Watcher error for library ${libraryId}: ${err.message}`);
return;
}
for (const event of events) {
if (event.type === 'delete' || event.type === 'create') {
this.schedule(event.type, event.path);
}
}
});
subs.push(sub);
}
this.watchers.set(libraryId, watcher);
this.subscriptions.set(libraryId, subs);
this.logger.log(`Watching ${paths.length} folder(s) for library ${libraryId}`);
}
async stopWatcher(libraryId: number): Promise<void> {
const existing = this.watchers.get(libraryId);
const existing = this.subscriptions.get(libraryId);
if (!existing) return;
await existing.close();
this.watchers.delete(libraryId);
for (const sub of existing) await sub.unsubscribe();
this.subscriptions.delete(libraryId);
}
private schedule(type: 'unlink' | 'unlinkDir', path: string): void {
const key = `${type}:${path}`;
clearTimeout(this.pendingTimers.get(key));
this.pendingTimers.set(
key,
setTimeout(() => {
this.pendingTimers.delete(key);
private schedule(type: EventType, path: string): void {
const existing = this.pendingTimers.get(path);
if (existing) clearTimeout(existing.timer);
const timer = setTimeout(() => {
this.pendingTimers.delete(path);
this.process(type, path).catch((err) => this.logger.error(`Failed to process ${type} for ${path}: ${(err as Error).message}`));
}, DEBOUNCE_MS),
);
}, DEBOUNCE_MS);
this.pendingTimers.set(path, { timer, type });
}
private async process(type: 'unlink' | 'unlinkDir', path: string): Promise<void> {
const result = type === 'unlink' ? await this.processor.handleUnlink(path) : await this.processor.handleUnlinkDir(path);
private async process(type: EventType, path: string): Promise<void> {
let result;
if (type === 'create') {
result = await this.processor.handleCreate(path);
} else {
result = await this.processor.handleUnlink(path);
if (result.type === 'noop') {
result = await this.processor.handleUnlinkDir(path);
}
}
if (result.type === 'book-missing') {
this.gateway.emitBookMissing({ libraryId: result.libraryId, bookIds: result.bookIds });
} else if (result.type === 'file-removed') {
this.gateway.emitBookFileRemoved({ libraryId: result.libraryId, bookId: result.bookId, fileId: result.fileId });
} else if (result.type === 'book-restored') {
this.gateway.emitBookRestored({ libraryId: result.libraryId, bookIds: result.bookIds });
}
}
}
@@ -1,5 +1,5 @@
import { ScanGateway } from './scan.gateway';
import type { BookFileRemovedEvent, BookMissingEvent, CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types';
import type { BookMissingEvent, CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types';
function makeGateway() {
const gateway = new ScanGateway(
@@ -39,29 +39,6 @@ describe('emitBookMissing', () => {
});
});
// ── book:file:removed ─────────────────────────────────────────────────────────
describe('emitBookFileRemoved', () => {
it('emits book:file:removed to the correct library room', () => {
const gateway = makeGateway();
const { server, to, emit } = mockServer();
gateway['server'] = server as any;
const event: BookFileRemovedEvent = { libraryId: 3, bookId: 10, fileId: 55 };
gateway.emitBookFileRemoved(event);
expect(to).toHaveBeenCalledWith('library:3');
expect(emit).toHaveBeenCalledWith('book:file:removed', event);
});
it('does not throw when server is undefined', () => {
const gateway = makeGateway();
gateway['server'] = undefined as any;
expect(() => gateway.emitBookFileRemoved({ libraryId: 1, bookId: 1, fileId: 1 })).not.toThrow();
});
});
// ── pre-existing emit methods (regression) ────────────────────────────────────
describe('emitProgress', () => {
+3 -3
View File
@@ -3,7 +3,7 @@ import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import type { BookFileRemovedEvent, BookMissingEvent, CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types';
import type { BookMissingEvent, BookRestoredEvent, CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types';
import { AuthService } from '../auth/auth.service';
import { ScanJobStore } from './scan-job-store.service';
@@ -75,7 +75,7 @@ export class ScanGateway implements OnGatewayConnection, OnGatewayDisconnect {
this.server?.to(`library:${event.libraryId}`).emit('book:missing', event);
}
emitBookFileRemoved(event: BookFileRemovedEvent): void {
this.server?.to(`library:${event.libraryId}`).emit('book:file:removed', event);
emitBookRestored(event: BookRestoredEvent): void {
this.server?.to(`library:${event.libraryId}`).emit('book:restored', event);
}
}
@@ -128,6 +128,21 @@ export class ScannerRepository {
return result?.count ?? 0;
}
async countPrimaryBookFilesByBookId(bookId: number): Promise<number> {
const [result] = await this.db
.select({ count: count() })
.from(bookFiles)
.where(and(eq(bookFiles.bookId, bookId), eq(bookFiles.role, 'primary')));
return result?.count ?? 0;
}
async findPrimaryBookFilesByBookId(bookId: number) {
return this.db
.select()
.from(bookFiles)
.where(and(eq(bookFiles.bookId, bookId), eq(bookFiles.role, 'primary')));
}
async findBooksByFolderPath(folderPath: string) {
return this.db
.select()
@@ -139,4 +154,33 @@ export class ScannerRepository {
if (bookIds.length === 0) return;
await this.db.delete(bookFiles).where(inArray(bookFiles.bookId, bookIds));
}
async findMissingBookByFolderPath(folderPath: string) {
const [row] = await this.db
.select()
.from(books)
.where(and(eq(books.folderPath, folderPath), eq(books.status, 'missing')))
.limit(1);
return row ?? null;
}
async findMissingBooksByFolderPath(folderPath: string) {
return this.db
.select()
.from(books)
.where(and(or(eq(books.folderPath, folderPath), like(books.folderPath, folderPath + '/%')), eq(books.status, 'missing')));
}
async findMissingBooksForLibraries(libraryIds: number[]) {
if (libraryIds.length === 0) return [];
return this.db
.select()
.from(books)
.where(and(inArray(books.libraryId, libraryIds), eq(books.status, 'missing')));
}
async markBooksAsPresent(bookIds: number[]) {
if (bookIds.length === 0) return;
await this.db.update(books).set({ status: 'present', updatedAt: new Date() }).where(inArray(books.id, bookIds));
}
}