mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix: preserve book file rows on delete so restored files keep their IDs
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user