mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -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 { 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'
|
import { getSocket } from '@/features/scanner/composables/useScanProgress'
|
||||||
|
|
||||||
type BookMissingCallback = (bookIds: number[]) => void
|
type BookMissingCallback = (bookIds: number[]) => void
|
||||||
type BookFileRemovedCallback = (bookId: number, fileId: number) => void
|
type BookRestoredCallback = (bookIds: number[]) => void
|
||||||
|
|
||||||
const missingCallbacks = new Set<BookMissingCallback>()
|
const missingCallbacks = new Set<BookMissingCallback>()
|
||||||
const fileRemovedCallbacks = new Set<BookFileRemovedCallback>()
|
const restoredCallbacks = new Set<BookRestoredCallback>()
|
||||||
|
|
||||||
let pendingMissingCount = 0
|
let pendingMissingCount = 0
|
||||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -34,8 +34,8 @@ function ensureInitialized() {
|
|||||||
toastTimer = setTimeout(flushMissingToast, 1000)
|
toastTimer = setTimeout(flushMissingToast, 1000)
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.on('book:file:removed', (event: BookFileRemovedEvent) => {
|
socket.on('book:restored', (event: BookRestoredEvent) => {
|
||||||
for (const cb of fileRemovedCallbacks) cb(event.bookId, event.fileId)
|
for (const cb of restoredCallbacks) cb(event.bookIds)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,10 +47,10 @@ export function useBookEvents() {
|
|||||||
return () => missingCallbacks.delete(cb)
|
return () => missingCallbacks.delete(cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBookFileRemoved(cb: BookFileRemovedCallback): () => void {
|
function onBookRestored(cb: BookRestoredCallback): () => void {
|
||||||
fileRemovedCallbacks.add(cb)
|
restoredCallbacks.add(cb)
|
||||||
return () => fileRemovedCallbacks.delete(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 { useBookDetail } from '@/features/book/composables/useBookDetail'
|
||||||
import { useBookEvents } from '@/features/book/composables/useBookEvents'
|
import { useBookEvents } from '@/features/book/composables/useBookEvents'
|
||||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||||
|
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -21,14 +22,17 @@ const activeTab = computed(() => (route.query.tab as string) || 'details')
|
|||||||
const { detail, loading, fetch } = useBookDetail()
|
const { detail, loading, fetch } = useBookDetail()
|
||||||
const { libraries, fetchLibraries } = useLibraries()
|
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) => {
|
onBookMissing((bookIds) => {
|
||||||
if (detail.value && bookIds.includes(detail.value.id)) {
|
if (detail.value && bookIds.includes(detail.value.id)) {
|
||||||
detail.value = { ...detail.value, status: 'missing', files: [] }
|
detail.value = { ...detail.value, status: 'missing' }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onBookFileRemoved((bookId) => {
|
onBookRestored((bookIds) => {
|
||||||
if (detail.value?.id === bookId) fetch(bookId)
|
if (detail.value && bookIds.includes(detail.value.id)) fetch(detail.value.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(bookId, (id) => fetch(id), { immediate: true })
|
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 { useBookEvents } from '@/features/book/composables/useBookEvents'
|
||||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||||
|
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
|
||||||
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||||
import type { SortField } from '@projectx/types'
|
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 { 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) => {
|
onBookMissing((bookIds) => {
|
||||||
const missing = new Set(bookIds)
|
const missing = new Set(bookIds)
|
||||||
for (const book of books.value) {
|
for (const book of books.value) {
|
||||||
if (missing.has(book.id)) book.status = 'missing'
|
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)
|
const filterOpen = ref(false)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -18,6 +18,6 @@
|
|||||||
"concurrently": "^9.2.1"
|
"concurrently": "^9.2.1"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"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[];
|
bookIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookFileRemovedEvent {
|
export interface BookRestoredEvent {
|
||||||
libraryId: number;
|
libraryId: number;
|
||||||
bookId: number;
|
bookIds: number[];
|
||||||
fileId: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -39,9 +39,9 @@
|
|||||||
"@nestjs/platform-socket.io": "^11.1.12",
|
"@nestjs/platform-socket.io": "^11.1.12",
|
||||||
"@nestjs/schedule": "^6.1.0",
|
"@nestjs/schedule": "^6.1.0",
|
||||||
"@nestjs/websockets": "^11.1.12",
|
"@nestjs/websockets": "^11.1.12",
|
||||||
|
"@parcel/watcher": "^2.5.6",
|
||||||
"@projectx/types": "workspace:*",
|
"@projectx/types": "workspace:*",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"chokidar": "5.0.0",
|
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1769655184878,
|
"when": 1769672547955,
|
||||||
"tag": "0000_baseline",
|
"tag": "0000_baseline",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,61 @@
|
|||||||
import { FileEventProcessorService } from './file-event-processor.service';
|
import { FileEventProcessorService } from './file-event-processor.service';
|
||||||
import { ScannerRepository } from './scanner.repository';
|
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<
|
const mockRepo: jest.Mocked<
|
||||||
Pick<
|
Pick<
|
||||||
ScannerRepository,
|
ScannerRepository,
|
||||||
| 'findBookFileByAbsolutePath'
|
| 'findBookFileByAbsolutePath'
|
||||||
| 'deleteBookFile'
|
|
||||||
| 'countBookFilesByBookId'
|
|
||||||
| 'markBooksAsMissing'
|
| 'markBooksAsMissing'
|
||||||
| 'findBooksByFolderPath'
|
| 'findBooksByFolderPath'
|
||||||
| 'deleteBookFilesByBookIds'
|
| 'findMissingBookByFolderPath'
|
||||||
|
| 'findMissingBooksByFolderPath'
|
||||||
|
| 'findMissingBooksForLibraries'
|
||||||
|
| 'findPrimaryBookFilesByBookId'
|
||||||
|
| 'createBookFile'
|
||||||
|
| 'updateBookFile'
|
||||||
|
| 'markBooksAsPresent'
|
||||||
>
|
>
|
||||||
> = {
|
> = {
|
||||||
findBookFileByAbsolutePath: jest.fn(),
|
findBookFileByAbsolutePath: jest.fn(),
|
||||||
deleteBookFile: jest.fn(),
|
|
||||||
countBookFilesByBookId: jest.fn(),
|
|
||||||
markBooksAsMissing: jest.fn(),
|
markBooksAsMissing: jest.fn(),
|
||||||
findBooksByFolderPath: 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() {
|
function makeService() {
|
||||||
return new FileEventProcessorService(mockRepo as unknown as ScannerRepository);
|
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(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
mockRepo.deleteBookFile.mockResolvedValue(undefined);
|
|
||||||
mockRepo.markBooksAsMissing.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 ──────────────────────────────────────────────────────────────
|
// ── handleUnlink ──────────────────────────────────────────────────────────────
|
||||||
@@ -40,33 +67,17 @@ describe('handleUnlink', () => {
|
|||||||
const result = await makeService().handleUnlink('/untracked/file.epub');
|
const result = await makeService().handleUnlink('/untracked/file.epub');
|
||||||
|
|
||||||
expect(result).toEqual({ type: 'noop' });
|
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(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({
|
mockRepo.findBookFileByAbsolutePath.mockResolvedValue({
|
||||||
file: { id: 99, bookId: 12 },
|
file: { id: 99, bookId: 12 },
|
||||||
libraryId: 5,
|
libraryId: 5,
|
||||||
} as any);
|
} as any);
|
||||||
mockRepo.countBookFilesByBookId.mockResolvedValue(0);
|
|
||||||
|
|
||||||
const result = await makeService().handleUnlink('/books/Solo/solo.epub');
|
const result = await makeService().handleUnlink('/books/Solo/solo.epub');
|
||||||
|
|
||||||
expect(mockRepo.deleteBookFile).toHaveBeenCalledWith(99);
|
|
||||||
expect(mockRepo.markBooksAsMissing).toHaveBeenCalledWith([12]);
|
expect(mockRepo.markBooksAsMissing).toHaveBeenCalledWith([12]);
|
||||||
expect(result).toEqual({ type: 'book-missing', libraryId: 5, bookIds: [12] });
|
expect(result).toEqual({ type: 'book-missing', libraryId: 5, bookIds: [12] });
|
||||||
});
|
});
|
||||||
@@ -81,30 +92,205 @@ describe('handleUnlinkDir', () => {
|
|||||||
const result = await makeService().handleUnlinkDir('/empty/folder');
|
const result = await makeService().handleUnlinkDir('/empty/folder');
|
||||||
|
|
||||||
expect(result).toEqual({ type: 'noop' });
|
expect(result).toEqual({ type: 'noop' });
|
||||||
expect(mockRepo.deleteBookFilesByBookIds).not.toHaveBeenCalled();
|
|
||||||
expect(mockRepo.markBooksAsMissing).not.toHaveBeenCalled();
|
expect(mockRepo.markBooksAsMissing).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns book-missing for a single-book folder', async () => {
|
it('marks books missing without deleting file rows', 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 () => {
|
|
||||||
mockRepo.findBooksByFolderPath.mockResolvedValue([
|
mockRepo.findBooksByFolderPath.mockResolvedValue([
|
||||||
{ id: 30, libraryId: 2 },
|
{ id: 20, libraryId: 1 },
|
||||||
{ id: 31, libraryId: 2 },
|
{ id: 21, libraryId: 1 },
|
||||||
] as any);
|
] 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([20, 21]);
|
||||||
expect(mockRepo.markBooksAsMissing).toHaveBeenCalledWith([30, 31]);
|
expect(result).toEqual({ type: 'book-missing', libraryId: 1, bookIds: [20, 21] });
|
||||||
expect(result).toEqual({ type: 'book-missing', libraryId: 2, bookIds: [30, 31] });
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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 { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { stat } from 'fs/promises';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
import { classifyFile } from './lib/classify';
|
||||||
import { ScannerRepository } from './scanner.repository';
|
import { ScannerRepository } from './scanner.repository';
|
||||||
|
|
||||||
export type FileEventResult =
|
export type FileEventResult =
|
||||||
| { type: 'book-missing'; libraryId: number; bookIds: number[] }
|
| { 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' };
|
| { type: 'noop' };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -18,17 +21,9 @@ export class FileEventProcessorService {
|
|||||||
if (!row) return { type: 'noop' };
|
if (!row) return { type: 'noop' };
|
||||||
|
|
||||||
const { file, libraryId } = row;
|
const { file, libraryId } = row;
|
||||||
await this.scannerRepo.deleteBookFile(file.id);
|
await this.scannerRepo.markBooksAsMissing([file.bookId]);
|
||||||
|
this.logger.log(`Book ${file.bookId} marked missing — file removed: ${absolutePath}`);
|
||||||
const remaining = await this.scannerRepo.countBookFilesByBookId(file.bookId);
|
return { type: 'book-missing', libraryId, bookIds: [file.bookId] };
|
||||||
if (remaining === 0) {
|
|
||||||
await this.scannerRepo.markBooksAsMissing([file.bookId]);
|
|
||||||
this.logger.log(`Book ${file.bookId} marked missing — last 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> {
|
async handleUnlinkDir(absolutePath: string): Promise<FileEventResult> {
|
||||||
@@ -38,10 +33,113 @@ export class FileEventProcessorService {
|
|||||||
const bookIds = matched.map((b) => b.id);
|
const bookIds = matched.map((b) => b.id);
|
||||||
const libraryId = matched[0]!.libraryId;
|
const libraryId = matched[0]!.libraryId;
|
||||||
|
|
||||||
await this.scannerRepo.deleteBookFilesByBookIds(bookIds);
|
|
||||||
await this.scannerRepo.markBooksAsMissing(bookIds);
|
await this.scannerRepo.markBooksAsMissing(bookIds);
|
||||||
|
|
||||||
this.logger.log(`${bookIds.length} book(s) marked missing — folder removed: ${absolutePath}`);
|
this.logger.log(`${bookIds.length} book(s) marked missing — folder removed: ${absolutePath}`);
|
||||||
return { type: 'book-missing', libraryId, bookIds };
|
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 { FileEventProcessorService } from './file-event-processor.service';
|
||||||
import { ScanGateway } from './scan.gateway';
|
import { ScanGateway } from './scan.gateway';
|
||||||
|
|
||||||
function makeService(processorResult: any = { type: 'noop' }) {
|
function makeService() {
|
||||||
const processor = {
|
const processor = {
|
||||||
handleUnlink: jest.fn().mockResolvedValue(processorResult),
|
handleUnlink: jest.fn().mockResolvedValue({ type: 'noop' }),
|
||||||
handleUnlinkDir: jest.fn().mockResolvedValue(processorResult),
|
handleUnlinkDir: jest.fn().mockResolvedValue({ type: 'noop' }),
|
||||||
|
handleCreate: jest.fn().mockResolvedValue({ type: 'noop' }),
|
||||||
|
reconcileMissingBooks: jest.fn().mockResolvedValue([]),
|
||||||
} as unknown as FileEventProcessorService;
|
} as unknown as FileEventProcessorService;
|
||||||
|
|
||||||
const gateway = {
|
const gateway = {
|
||||||
emitBookMissing: jest.fn(),
|
emitBookMissing: jest.fn(),
|
||||||
emitBookFileRemoved: jest.fn(),
|
emitBookRestored: jest.fn(),
|
||||||
} as unknown as ScanGateway;
|
} as unknown as ScanGateway;
|
||||||
|
|
||||||
// DB stub — onApplicationBootstrap queries libraries; tests don't call it so db is unused
|
|
||||||
const db = {} as any;
|
const db = {} as any;
|
||||||
|
|
||||||
const service = new FileWatcherService(db, processor, gateway);
|
const service = new FileWatcherService(db, processor, gateway);
|
||||||
return { service, processor, gateway };
|
return { service, processor, gateway };
|
||||||
}
|
}
|
||||||
@@ -26,49 +26,48 @@ afterEach(() => jest.useRealTimers());
|
|||||||
// ── process() routing ─────────────────────────────────────────────────────────
|
// ── process() routing ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('process()', () => {
|
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 missing = { type: 'book-missing', libraryId: 1, bookIds: [10, 11] };
|
||||||
const { service, processor, gateway } = makeService(missing);
|
|
||||||
processor.handleUnlink = jest.fn().mockResolvedValue(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.emitBookMissing).toHaveBeenCalledWith({ libraryId: 1, bookIds: [10, 11] });
|
||||||
expect(gateway.emitBookFileRemoved).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls emitBookFileRemoved when processor returns file-removed', async () => {
|
it('falls back to handleUnlinkDir when handleUnlink returns noop on delete', async () => {
|
||||||
const removed = { type: 'file-removed', libraryId: 2, bookId: 5, fileId: 99 };
|
const { service, processor, gateway } = makeService();
|
||||||
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 () => {
|
|
||||||
const missing = { type: 'book-missing', libraryId: 3, bookIds: [20] };
|
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);
|
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.handleUnlinkDir).toHaveBeenCalledWith('/books/Author');
|
||||||
expect(processor.handleUnlink).not.toHaveBeenCalled();
|
|
||||||
expect(gateway.emitBookMissing).toHaveBeenCalledWith({ libraryId: 3, bookIds: [20] });
|
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 ───────────────────────────────────────────────────────
|
// ── schedule() debounce ───────────────────────────────────────────────────────
|
||||||
@@ -78,23 +77,37 @@ describe('schedule() debounce', () => {
|
|||||||
const { service } = makeService();
|
const { service } = makeService();
|
||||||
const processSpy = jest.spyOn(service as any, 'process').mockResolvedValue(undefined);
|
const processSpy = jest.spyOn(service as any, 'process').mockResolvedValue(undefined);
|
||||||
|
|
||||||
(service as any).schedule('unlink', '/books/file.epub');
|
(service as any).schedule('delete', '/books/file.epub');
|
||||||
(service as any).schedule('unlink', '/books/file.epub'); // cancels previous
|
(service as any).schedule('delete', '/books/file.epub');
|
||||||
(service as any).schedule('unlink', '/books/file.epub'); // cancels previous
|
(service as any).schedule('delete', '/books/file.epub');
|
||||||
|
|
||||||
jest.runAllTimers();
|
jest.runAllTimers();
|
||||||
await Promise.resolve(); // flush microtasks
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(processSpy).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('does not debounce events for different paths', async () => {
|
||||||
const { service } = makeService();
|
const { service } = makeService();
|
||||||
const processSpy = jest.spyOn(service as any, 'process').mockResolvedValue(undefined);
|
const processSpy = jest.spyOn(service as any, 'process').mockResolvedValue(undefined);
|
||||||
|
|
||||||
(service as any).schedule('unlink', '/books/file-a.epub');
|
(service as any).schedule('delete', '/books/file-a.epub');
|
||||||
(service as any).schedule('unlink', '/books/file-b.epub');
|
(service as any).schedule('delete', '/books/file-b.epub');
|
||||||
|
|
||||||
jest.runAllTimers();
|
jest.runAllTimers();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
@@ -102,3 +115,40 @@ describe('schedule() debounce', () => {
|
|||||||
expect(processSpy).toHaveBeenCalledTimes(2);
|
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 { Inject, Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
import type { FSWatcher } from 'chokidar';
|
import type { AsyncSubscription } from '@parcel/watcher';
|
||||||
|
|
||||||
import { DB } from '../../db';
|
import { DB } from '../../db';
|
||||||
import * as schema from '../../db/schema';
|
import * as schema from '../../db/schema';
|
||||||
@@ -10,14 +10,17 @@ import { ScanGateway } from './scan.gateway';
|
|||||||
import { FileEventProcessorService } from './file-event-processor.service';
|
import { FileEventProcessorService } from './file-event-processor.service';
|
||||||
|
|
||||||
type Db = NodePgDatabase<typeof schema>;
|
type Db = NodePgDatabase<typeof schema>;
|
||||||
|
type EventType = 'delete' | 'create';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 500;
|
const DEBOUNCE_MS = 500;
|
||||||
|
const RECONCILE_MS = 30_000;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestroy {
|
export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(FileWatcherService.name);
|
private readonly logger = new Logger(FileWatcherService.name);
|
||||||
private readonly watchers = new Map<number, FSWatcher>();
|
private readonly subscriptions = new Map<number, AsyncSubscription[]>();
|
||||||
private readonly pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
private readonly pendingTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; type: EventType }>();
|
||||||
|
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DB) private readonly db: Db,
|
@Inject(DB) private readonly db: Db,
|
||||||
@@ -34,56 +37,92 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
|
|||||||
folders.map((f) => f.path),
|
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> {
|
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();
|
this.pendingTimers.clear();
|
||||||
for (const watcher of this.watchers.values()) await watcher.close();
|
for (const subs of this.subscriptions.values()) {
|
||||||
this.watchers.clear();
|
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> {
|
async startWatcher(libraryId: number, paths: string[]): Promise<void> {
|
||||||
await this.stopWatcher(libraryId);
|
await this.stopWatcher(libraryId);
|
||||||
if (paths.length === 0) return;
|
if (paths.length === 0) return;
|
||||||
|
|
||||||
const { watch } = await import('chokidar');
|
const { subscribe } = await import('@parcel/watcher');
|
||||||
const watcher = watch(paths, { ignoreInitial: true, persistent: true });
|
const subs: AsyncSubscription[] = [];
|
||||||
|
|
||||||
watcher.on('unlink', (path) => this.schedule('unlink', path));
|
for (const path of paths) {
|
||||||
watcher.on('unlinkDir', (path) => this.schedule('unlinkDir', path));
|
const sub = await subscribe(path, (err, events) => {
|
||||||
watcher.on('error', (err) => this.logger.warn(`Watcher error for library ${libraryId}: ${err}`));
|
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}`);
|
this.logger.log(`Watching ${paths.length} folder(s) for library ${libraryId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async stopWatcher(libraryId: number): Promise<void> {
|
async stopWatcher(libraryId: number): Promise<void> {
|
||||||
const existing = this.watchers.get(libraryId);
|
const existing = this.subscriptions.get(libraryId);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
await existing.close();
|
for (const sub of existing) await sub.unsubscribe();
|
||||||
this.watchers.delete(libraryId);
|
this.subscriptions.delete(libraryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private schedule(type: 'unlink' | 'unlinkDir', path: string): void {
|
private schedule(type: EventType, path: string): void {
|
||||||
const key = `${type}:${path}`;
|
const existing = this.pendingTimers.get(path);
|
||||||
clearTimeout(this.pendingTimers.get(key));
|
if (existing) clearTimeout(existing.timer);
|
||||||
this.pendingTimers.set(
|
const timer = setTimeout(() => {
|
||||||
key,
|
this.pendingTimers.delete(path);
|
||||||
setTimeout(() => {
|
this.process(type, path).catch((err) => this.logger.error(`Failed to process ${type} for ${path}: ${(err as Error).message}`));
|
||||||
this.pendingTimers.delete(key);
|
}, DEBOUNCE_MS);
|
||||||
this.process(type, path).catch((err) => this.logger.error(`Failed to process ${type} for ${path}: ${(err as Error).message}`));
|
this.pendingTimers.set(path, { timer, type });
|
||||||
}, DEBOUNCE_MS),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async process(type: 'unlink' | 'unlinkDir', path: string): Promise<void> {
|
private async process(type: EventType, path: string): Promise<void> {
|
||||||
const result = type === 'unlink' ? await this.processor.handleUnlink(path) : await this.processor.handleUnlinkDir(path);
|
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') {
|
if (result.type === 'book-missing') {
|
||||||
this.gateway.emitBookMissing({ libraryId: result.libraryId, bookIds: result.bookIds });
|
this.gateway.emitBookMissing({ libraryId: result.libraryId, bookIds: result.bookIds });
|
||||||
} else if (result.type === 'file-removed') {
|
} else if (result.type === 'book-restored') {
|
||||||
this.gateway.emitBookFileRemoved({ libraryId: result.libraryId, bookId: result.bookId, fileId: result.fileId });
|
this.gateway.emitBookRestored({ libraryId: result.libraryId, bookIds: result.bookIds });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ScanGateway } from './scan.gateway';
|
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() {
|
function makeGateway() {
|
||||||
const gateway = new ScanGateway(
|
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) ────────────────────────────────────
|
// ── pre-existing emit methods (regression) ────────────────────────────────────
|
||||||
|
|
||||||
describe('emitProgress', () => {
|
describe('emitProgress', () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import { OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
import { OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||||
import { Server, Socket } from 'socket.io';
|
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 { AuthService } from '../auth/auth.service';
|
||||||
import { ScanJobStore } from './scan-job-store.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);
|
this.server?.to(`library:${event.libraryId}`).emit('book:missing', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
emitBookFileRemoved(event: BookFileRemovedEvent): void {
|
emitBookRestored(event: BookRestoredEvent): void {
|
||||||
this.server?.to(`library:${event.libraryId}`).emit('book:file:removed', event);
|
this.server?.to(`library:${event.libraryId}`).emit('book:restored', event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,21 @@ export class ScannerRepository {
|
|||||||
return result?.count ?? 0;
|
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) {
|
async findBooksByFolderPath(folderPath: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.select()
|
.select()
|
||||||
@@ -139,4 +154,33 @@ export class ScannerRepository {
|
|||||||
if (bookIds.length === 0) return;
|
if (bookIds.length === 0) return;
|
||||||
await this.db.delete(bookFiles).where(inArray(bookFiles.bookId, bookIds));
|
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