feat(scanner): detect missing books via watcher and propagate to UI

This commit is contained in:
neon
2026-01-28 07:58:26 -06:00
parent 14ebb843bd
commit 426bc5fc82
22 changed files with 1083 additions and 70 deletions
@@ -4,7 +4,7 @@ import { FORMAT_TO_GROUP } from '@projectx/types'
import { bookCoverStyle } from '../lib/book-cover'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2 } from 'lucide-vue-next'
import { BookOpen, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2, TriangleAlert } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useCoverVersions } from '../composables/useCoverVersions'
@@ -32,6 +32,7 @@ const coverSrc = computed(() => coverUrl(props.book.id))
const coverLoaded = ref(false)
const coverFailed = ref(false)
const isMissing = computed(() => props.book.status === 'missing')
function openFile(file: BookFileRef) {
router.push({
@@ -45,12 +46,13 @@ function openFile(file: BookFileRef) {
<template>
<div
class="group flex flex-col @container"
:class="primaryFile ? 'cursor-pointer' : 'cursor-default opacity-60'"
@click="primaryFile && openFile(primaryFile)"
:class="primaryFile && !isMissing ? 'cursor-pointer' : 'cursor-default'"
@click="primaryFile && !isMissing && openFile(primaryFile)"
>
<!-- Cover -->
<div
class="relative w-full rounded-sm overflow-hidden shadow-md group-hover:shadow-xl group-hover:scale-[1.02] transition-all duration-150"
class="relative w-full rounded-sm overflow-hidden shadow-md transition-all duration-150"
:class="isMissing ? 'grayscale opacity-60' : 'group-hover:shadow-xl group-hover:scale-[1.02]'"
style="aspect-ratio: 2/3"
:style="coverLoaded ? {} : coverStyle"
>
@@ -75,9 +77,12 @@ function openFile(file: BookFileRef) {
</span>
</div>
<!-- Missing overlay -->
<div v-if="book.status === 'missing'" class="absolute inset-0 bg-black/60 flex items-center justify-center z-10">
<span class="text-[10px] font-semibold uppercase tracking-widest text-destructive-foreground bg-destructive px-2 py-0.5 rounded">
<!-- Missing badge -->
<div v-if="isMissing" class="absolute top-1.5 right-1.5 z-20">
<span
class="flex items-center gap-1 text-[9px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded bg-amber-500/90 text-white backdrop-blur-sm"
>
<TriangleAlert class="size-2.5 shrink-0" />
Missing
</span>
</div>
@@ -3,7 +3,7 @@ import type { BookCard, BookFileRef } from '@projectx/types'
import { bookCoverStyle } from '../lib/book-cover'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2 } from 'lucide-vue-next'
import { BookOpen, ExternalLink, FolderPlus, MoreHorizontal, PanelRight, Pencil, Trash2, TriangleAlert } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useCoverVersions } from '../composables/useCoverVersions'
@@ -22,6 +22,7 @@ const seriesLine = computed(() => {
return idx != null ? `${props.book.seriesName} #${idx % 1 === 0 ? Math.floor(idx) : idx}` : props.book.seriesName
})
const isMissing = computed(() => props.book.status === 'missing')
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
const extraFiles = computed(() => (props.book.files.length > 1 ? props.book.files : []))
@@ -43,8 +44,8 @@ function openFile(file: BookFileRef) {
<template>
<div
class="flex items-center gap-3 py-2 px-2 rounded-md transition-colors"
:class="primaryFile ? 'cursor-pointer hover:bg-muted/50' : 'cursor-default opacity-60'"
@click="primaryFile && openFile(primaryFile)"
:class="[primaryFile && !isMissing ? 'cursor-pointer hover:bg-muted/50' : 'cursor-default', isMissing ? 'opacity-60 grayscale' : '']"
@click="primaryFile && !isMissing && openFile(primaryFile)"
>
<!-- Cover -->
<div class="h-16 w-12 rounded shrink-0 overflow-hidden relative" :style="coverLoaded ? {} : coverStyle">
@@ -71,9 +72,10 @@ function openFile(file: BookFileRef) {
<div class="flex items-center gap-2 shrink-0" @click.stop>
<div class="flex flex-col items-end gap-1">
<span
v-if="book.status === 'missing'"
class="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-destructive text-destructive-foreground"
v-if="isMissing"
class="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-600 dark:text-amber-400"
>
<TriangleAlert class="size-3 shrink-0" />
Missing
</span>
<button
@@ -0,0 +1,91 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi, beforeAll } from 'vitest'
import BookCoverCard from '../BookCoverCard.vue'
import type { BookCard } from '@projectx/types'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/features/book/composables/useCoverVersions', () => ({
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }),
}))
vi.mock('@/features/book/lib/book-cover', () => ({
bookCoverStyle: () => ({ background: 'oklch(0.22 0.07 200)', color: 'oklch(0.92 0.03 200)' }),
}))
// Stub complex UI sub-components
const globalStubs = {
stubs: {
DropdownMenu: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuSeparator: { template: '<div />' },
},
}
const missingBook: BookCard = {
id: 1,
status: 'missing',
title: 'Gone Book',
authors: ['Test Author'],
seriesName: null,
seriesIndex: null,
files: [],
}
const presentBook: BookCard = {
id: 2,
status: 'present',
title: 'Available Book',
authors: ['Test Author'],
seriesName: null,
seriesIndex: null,
files: [{ id: 10, format: 'epub', role: 'primary' }],
}
describe('BookCoverCard — missing state', () => {
it('applies grayscale and opacity-60 to the cover container', () => {
const wrapper = mount(BookCoverCard, { props: { book: missingBook }, global: globalStubs })
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
expect(coverDiv.classes()).toContain('grayscale')
expect(coverDiv.classes()).toContain('opacity-60')
})
it('does not apply hover-scale to the cover container', () => {
const wrapper = mount(BookCoverCard, { props: { book: missingBook }, global: globalStubs })
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
const classes = coverDiv.classes().join(' ')
expect(classes).not.toContain('group-hover:scale-[1.02]')
})
it('renders the amber missing badge with TriangleAlert icon', () => {
const wrapper = mount(BookCoverCard, { props: { book: missingBook }, global: globalStubs })
const badge = wrapper.find('[class*="bg-amber-500"]')
expect(badge.exists()).toBe(true)
expect(badge.text().toLowerCase()).toContain('missing')
})
it('uses cursor-default on the root when book is missing', () => {
const wrapper = mount(BookCoverCard, { props: { book: missingBook }, global: globalStubs })
expect(wrapper.classes()).toContain('cursor-default')
})
})
describe('BookCoverCard — present state', () => {
it('does not apply grayscale to the cover container', () => {
const wrapper = mount(BookCoverCard, { props: { book: presentBook }, global: globalStubs })
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
expect(coverDiv.classes()).not.toContain('grayscale')
})
it('does not render the missing badge', () => {
const wrapper = mount(BookCoverCard, { props: { book: presentBook }, global: globalStubs })
expect(wrapper.find('[class*="bg-amber-500"]').exists()).toBe(false)
})
it('applies hover-scale to cover container when present', () => {
const wrapper = mount(BookCoverCard, { props: { book: presentBook }, global: globalStubs })
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
const classes = coverDiv.classes().join(' ')
expect(classes).toContain('group-hover:scale-[1.02]')
})
})
@@ -0,0 +1,83 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import BookListRow from '../BookListRow.vue'
import type { BookCard } from '@projectx/types'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/features/book/composables/useCoverVersions', () => ({
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }),
}))
vi.mock('@/features/book/lib/book-cover', () => ({
bookCoverStyle: () => ({ background: 'oklch(0.22 0.07 200)', color: 'oklch(0.92 0.03 200)' }),
}))
const globalStubs = {
stubs: {
DropdownMenu: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuSeparator: { template: '<div />' },
},
}
const missingBook: BookCard = {
id: 1,
status: 'missing',
title: 'Gone Book',
authors: ['Test Author'],
seriesName: null,
seriesIndex: null,
files: [],
}
const presentBook: BookCard = {
id: 2,
status: 'present',
title: 'Available Book',
authors: ['Test Author'],
seriesName: null,
seriesIndex: null,
files: [{ id: 10, format: 'epub', role: 'primary' }],
}
describe('BookListRow — missing state', () => {
it('applies grayscale and opacity-60 to the root row', () => {
const wrapper = mount(BookListRow, { props: { book: missingBook }, global: globalStubs })
const root = wrapper.find('.flex.items-center')
expect(root.classes()).toContain('grayscale')
expect(root.classes()).toContain('opacity-60')
})
it('renders the amber missing badge', () => {
const wrapper = mount(BookListRow, { props: { book: missingBook }, global: globalStubs })
const badge = wrapper.find('[class*="bg-amber-500"]')
expect(badge.exists()).toBe(true)
expect(badge.text().toLowerCase()).toContain('missing')
})
it('does not apply hover:bg-muted on the row when missing', () => {
const wrapper = mount(BookListRow, { props: { book: missingBook }, global: globalStubs })
const root = wrapper.find('.flex.items-center')
expect(root.classes().join(' ')).not.toContain('hover:bg-muted')
})
})
describe('BookListRow — present state', () => {
it('does not apply grayscale to the root row', () => {
const wrapper = mount(BookListRow, { props: { book: presentBook }, global: globalStubs })
const root = wrapper.find('.flex.items-center')
expect(root.classes()).not.toContain('grayscale')
})
it('does not render the missing badge', () => {
const wrapper = mount(BookListRow, { props: { book: presentBook }, global: globalStubs })
expect(wrapper.find('[class*="bg-amber-500"]').exists()).toBe(false)
})
it('applies hover:bg-muted/50 when present with a readable file', () => {
const wrapper = mount(BookListRow, { props: { book: presentBook }, global: globalStubs })
const root = wrapper.find('.flex.items-center')
expect(root.classes().join(' ')).toContain('hover:bg-muted')
})
})
@@ -0,0 +1,83 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import DetailsTab from '../detail/tabs/DetailsTab.vue'
import type { BookDetail } from '@projectx/types'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }))
vi.mock('@/features/book/composables/useCoverVersions', () => ({
useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }),
}))
vi.mock('@/features/book/lib/book-cover', () => ({
bookCoverStyle: () => ({ background: 'oklch(0.22 0.07 200)', color: 'oklch(0.92 0.03 200)' }),
}))
const globalStubs = {
stubs: {
DialogRoot: { template: '<div><slot /></div>' },
DialogPortal: { template: '<div><slot /></div>' },
DialogOverlay: { template: '<div />' },
DialogContent: { template: '<div><slot /></div>' },
DialogClose: { template: '<button><slot /></button>' },
},
}
function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
return {
id: 1,
libraryId: 1,
status: 'present',
title: 'Test Book',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
pageCount: null,
seriesName: null,
seriesIndex: null,
authors: [],
tags: [],
files: [],
...overrides,
}
}
describe('DetailsTab — missing state', () => {
it('renders the amber warning banner', () => {
const wrapper = mount(DetailsTab, {
props: { book: makeBook({ status: 'missing' }) },
global: globalStubs,
})
const banner = wrapper.find('[class*="border-amber-500"]')
expect(banner.exists()).toBe(true)
})
it('shows "Files not found" heading in the banner', () => {
const wrapper = mount(DetailsTab, {
props: { book: makeBook({ status: 'missing' }) },
global: globalStubs,
})
expect(wrapper.text()).toContain('Files not found')
})
it('mentions disk in the banner description', () => {
const wrapper = mount(DetailsTab, {
props: { book: makeBook({ status: 'missing' }) },
global: globalStubs,
})
expect(wrapper.text().toLowerCase()).toContain('disk')
})
})
describe('DetailsTab — present state', () => {
it('does not render the warning banner', () => {
const wrapper = mount(DetailsTab, {
props: { book: makeBook({ status: 'present' }) },
global: globalStubs,
})
expect(wrapper.find('[class*="border-amber-500"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('Files not found')
})
})
@@ -1,15 +1,13 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, Download, FolderPlus, Trash2, X } from 'lucide-vue-next'
import { BookOpen, Download, FolderPlus, Trash2, TriangleAlert, X } from 'lucide-vue-next'
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } from 'reka-ui'
import { Skeleton } from '@/components/ui/skeleton'
import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import type { BookDetail } from '@projectx/types'
const props = defineProps<{ book: BookDetail }>()
const router = useRouter()
const coverLoaded = ref(false)
@@ -18,13 +16,12 @@ const coverLightboxOpen = ref(false)
const descriptionExpanded = ref(false)
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
const { coverUrl } = useCoverVersions()
const coverSrc = computed(() => coverUrl(props.book.id, 'cover'))
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
const authorLine = computed(() => props.book.authors.map((a) => a.name).join(', ') || null)
const formats = computed(() => [...new Set(props.book.files.map((f) => f.format ?? '?'))])
const seriesLine = computed(() => {
if (!props.book.seriesName) return null
@@ -32,8 +29,6 @@ const seriesLine = computed(() => {
return idx != null ? `${props.book.seriesName} #${idx % 1 === 0 ? Math.floor(idx) : idx}` : props.book.seriesName
})
const isbn = computed(() => props.book.isbn13 ?? props.book.isbn10 ?? null)
function openBook() {
if (!primaryFile.value) return
router.push({
@@ -53,11 +48,20 @@ function downloadFile() {
</script>
<template>
<div v-if="book.status === 'missing'" class="mb-6 flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3">
<TriangleAlert class="size-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<p class="text-sm font-medium text-amber-600 dark:text-amber-400">Files not found</p>
<p class="text-xs text-muted-foreground mt-0.5">
The file(s) for this book can no longer be found on disk. Metadata is still available. Run a library scan to confirm, or remove the record.
</p>
</div>
</div>
<div class="flex flex-col md:flex-row gap-8">
<!-- Left column: cover + actions -->
<div class="md:w-56 shrink-0 md:sticky md:top-4 md:self-start">
<div class="max-w-48 mx-auto md:max-w-none">
<!-- Cover -->
<div
class="w-full rounded-sm overflow-hidden shadow-md cursor-zoom-in"
style="aspect-ratio: 2/3"
@@ -75,7 +79,6 @@ function downloadFile() {
/>
</div>
<!-- Actions -->
<div class="mt-4 space-y-2">
<button
class="flex w-full items-center justify-center gap-2 h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
@@ -111,63 +114,89 @@ function downloadFile() {
</div>
</div>
<!-- Right column: metadata -->
<!-- Right column -->
<div class="flex-1 min-w-0">
<!-- Title block -->
<!-- Identity block -->
<h1 class="text-2xl font-bold leading-tight">{{ book.title ?? 'Untitled' }}</h1>
<p v-if="book.subtitle" class="text-base text-muted-foreground mt-1">{{ book.subtitle }}</p>
<p v-if="authorLine" class="text-sm text-foreground/80 mt-2">{{ authorLine }}</p>
<p v-if="seriesLine" class="text-sm text-muted-foreground italic mt-0.5">{{ seriesLine }}</p>
<p v-if="book.subtitle" class="text-base text-muted-foreground mt-1 leading-snug">{{ book.subtitle }}</p>
<!-- Badges -->
<div class="flex flex-wrap gap-1.5 mt-4">
<div class="flex items-baseline flex-wrap gap-x-2 gap-y-1 mt-3">
<p v-if="authorLine" class="text-sm">
<span class="text-muted-foreground">by</span>
<span class="ml-1 font-medium text-foreground">{{ authorLine }}</span>
</p>
<template v-if="seriesLine">
<span class="text-muted-foreground/40 text-xs">·</span>
<span class="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">{{ seriesLine }}</span>
</template>
</div>
<!-- Format badges -->
<div v-if="formats.length" class="flex flex-wrap gap-1.5 mt-4">
<span
v-for="file in book.files"
:key="file.id"
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-muted text-muted-foreground"
v-for="fmt in formats"
:key="fmt"
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded border border-border text-muted-foreground"
>
{{ file.format ?? '?' }}
</span>
<span v-if="book.pageCount" class="text-[10px] font-semibold px-2 py-0.5 rounded bg-muted text-muted-foreground">
{{ book.pageCount }} pages
</span>
<span v-if="book.publishedYear" class="text-[10px] font-semibold px-2 py-0.5 rounded bg-muted text-muted-foreground">
{{ book.publishedYear }}
</span>
<span v-if="book.language" class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-muted text-muted-foreground">
{{ book.language }}
{{ fmt }}
</span>
</div>
<!-- Metadata grid -->
<dl
v-if="book.publisher || book.publishedYear || book.language || book.pageCount || book.isbn13 || book.isbn10"
class="mt-5 pt-5 border-t border-border grid grid-cols-2 gap-x-8 gap-y-4"
>
<div v-if="book.publisher" class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Publisher</dt>
<dd class="text-sm text-foreground mt-0.5 leading-snug">{{ book.publisher }}</dd>
</div>
<div v-if="book.publishedYear">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Published</dt>
<dd class="text-sm text-foreground mt-0.5">{{ book.publishedYear }}</dd>
</div>
<div v-if="book.language">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Language</dt>
<dd class="text-sm text-foreground mt-0.5 capitalize">{{ book.language }}</dd>
</div>
<div v-if="book.pageCount">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Pages</dt>
<dd class="text-sm text-foreground mt-0.5">{{ book.pageCount }}</dd>
</div>
<div v-if="book.isbn13" class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">ISBN-13</dt>
<dd class="text-sm text-foreground mt-0.5 font-mono">{{ book.isbn13 }}</dd>
</div>
<div v-if="book.isbn10" class="min-w-0">
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">ISBN-10</dt>
<dd class="text-sm text-foreground mt-0.5 font-mono">{{ book.isbn10 }}</dd>
</div>
</dl>
<!-- Tags -->
<div v-if="book.tags.length" class="flex flex-wrap gap-1.5 mt-3">
<span v-for="tag in book.tags" :key="tag" class="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary">
<div v-if="book.tags.length" class="flex flex-wrap gap-1.5 mt-5">
<span v-for="tag in book.tags" :key="tag" class="text-xs px-2.5 py-0.5 rounded-full border border-primary/30 text-primary/80">
{{ tag }}
</span>
</div>
<!-- Publisher + ISBN -->
<div class="mt-3 space-y-0.5">
<p v-if="book.publisher" class="text-xs text-muted-foreground">{{ book.publisher }}</p>
<p v-if="isbn" class="text-xs text-muted-foreground">ISBN: {{ isbn }}</p>
</div>
<!-- Description -->
<div class="border-t mt-4 pt-4">
<!-- Synopsis -->
<div class="mt-6 pt-5 border-t border-border">
<p class="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground mb-3">Synopsis</p>
<div v-if="book.description">
<div
class="text-sm leading-relaxed text-foreground/80 transition-all"
:class="descriptionExpanded ? '' : 'line-clamp-4'"
:class="descriptionExpanded ? '' : 'line-clamp-6'"
v-html="book.description"
/>
<button
class="text-xs text-muted-foreground hover:text-foreground mt-1.5 transition-colors"
class="text-xs text-muted-foreground hover:text-foreground mt-2 transition-colors"
@click="descriptionExpanded = !descriptionExpanded"
>
{{ descriptionExpanded ? 'Show less' : 'Show more' }}
</button>
</div>
<p v-else class="text-xs text-muted-foreground italic">No description available.</p>
<p v-else class="text-sm text-muted-foreground italic">No description available.</p>
</div>
</div>
</div>
@@ -0,0 +1,122 @@
import { EventEmitter } from 'events'
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'
import { toast } from 'vue-sonner'
// ── Hoisted mocks ─────────────────────────────────────────────────────────────
// vi.mock factories are hoisted before any const/let, so all references inside
// must be self-contained (no outer variables).
const mockSocket = new EventEmitter()
vi.mock('@/features/scanner/composables/useScanProgress', () => ({
getSocket: () => mockSocket,
}))
vi.mock('vue-sonner', () => ({
toast: { warning: vi.fn() },
}))
// Import the composable after mocks are in place.
import { useBookEvents } from '@/features/book/composables/useBookEvents'
// Shorthand to the hoisted mock fn — accessed via vi.mocked after module loads.
const toastWarning = vi.mocked(toast.warning)
beforeAll(() => {
useBookEvents() // triggers ensureInitialized(), registers socket listeners
})
beforeEach(() => {
vi.useFakeTimers()
toastWarning.mockClear()
vi.clearAllTimers()
})
afterEach(() => {
vi.runAllTimers()
vi.useRealTimers()
})
// ── onBookMissing ──────────────────────────────────────────────────────────────
describe('onBookMissing', () => {
it('fires the callback with the bookIds array on book:missing event', () => {
const { onBookMissing } = useBookEvents()
const cb = vi.fn()
const cleanup = onBookMissing(cb)
mockSocket.emit('book:missing', { libraryId: 1, bookIds: [10, 20, 30] })
expect(cb).toHaveBeenCalledTimes(1)
expect(cb).toHaveBeenCalledWith([10, 20, 30])
cleanup()
})
it('cleanup function deregisters the callback', () => {
const { onBookMissing } = useBookEvents()
const cb = vi.fn()
const cleanup = onBookMissing(cb)
cleanup()
mockSocket.emit('book:missing', { libraryId: 1, bookIds: [1] })
expect(cb).not.toHaveBeenCalled()
})
it('shows singular toast for 1 missing book after 1s debounce', () => {
const { onBookMissing } = useBookEvents()
const cleanup = onBookMissing(() => {})
mockSocket.emit('book:missing', { libraryId: 1, bookIds: [99] })
expect(toastWarning).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(toastWarning).toHaveBeenCalledTimes(1)
expect(toastWarning).toHaveBeenCalledWith('1 book is no longer available on disk.')
cleanup()
})
it('batches multiple events into one toast with the total count', () => {
const { onBookMissing } = useBookEvents()
const cleanup = onBookMissing(() => {})
mockSocket.emit('book:missing', { libraryId: 1, bookIds: [1, 2, 3] })
mockSocket.emit('book:missing', { libraryId: 1, bookIds: [4, 5] })
expect(toastWarning).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(toastWarning).toHaveBeenCalledTimes(1)
expect(toastWarning).toHaveBeenCalledWith('5 books are no longer available on disk.')
cleanup()
})
})
// ── 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()
})
})
@@ -0,0 +1,56 @@
import { toast } from 'vue-sonner'
import type { BookFileRemovedEvent, BookMissingEvent } from '@projectx/types'
import { getSocket } from '@/features/scanner/composables/useScanProgress'
type BookMissingCallback = (bookIds: number[]) => void
type BookFileRemovedCallback = (bookId: number, fileId: number) => void
const missingCallbacks = new Set<BookMissingCallback>()
const fileRemovedCallbacks = new Set<BookFileRemovedCallback>()
let pendingMissingCount = 0
let toastTimer: ReturnType<typeof setTimeout> | null = null
function flushMissingToast() {
if (pendingMissingCount === 0) return
const count = pendingMissingCount
pendingMissingCount = 0
toastTimer = null
toast.warning(count === 1 ? '1 book is no longer available on disk.' : `${count} books are no longer available on disk.`)
}
let initialized = false
function ensureInitialized() {
if (initialized) return
initialized = true
const socket = getSocket()
socket.on('book:missing', (event: BookMissingEvent) => {
for (const cb of missingCallbacks) cb(event.bookIds)
pendingMissingCount += event.bookIds.length
clearTimeout(toastTimer ?? undefined)
toastTimer = setTimeout(flushMissingToast, 1000)
})
socket.on('book:file:removed', (event: BookFileRemovedEvent) => {
for (const cb of fileRemovedCallbacks) cb(event.bookId, event.fileId)
})
}
export function useBookEvents() {
ensureInitialized()
function onBookMissing(cb: BookMissingCallback): () => void {
missingCallbacks.add(cb)
return () => missingCallbacks.delete(cb)
}
function onBookFileRemoved(cb: BookFileRemovedCallback): () => void {
fileRemovedCallbacks.add(cb)
return () => fileRemovedCallbacks.delete(cb)
}
return { onBookMissing, onBookFileRemoved }
}
+11
View File
@@ -9,6 +9,7 @@ import BookDetailTabs from '@/features/book/components/detail/BookDetailTabs.vue
import DetailsTab from '@/features/book/components/detail/tabs/DetailsTab.vue'
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'
const route = useRoute()
@@ -20,6 +21,16 @@ const activeTab = computed(() => (route.query.tab as string) || 'details')
const { detail, loading, fetch } = useBookDetail()
const { libraries, fetchLibraries } = useLibraries()
const { onBookMissing, onBookFileRemoved } = useBookEvents()
onBookMissing((bookIds) => {
if (detail.value && bookIds.includes(detail.value.id)) {
detail.value = { ...detail.value, status: 'missing', files: [] }
}
})
onBookFileRemoved((bookId) => {
if (detail.value?.id === bookId) fetch(bookId)
})
watch(bookId, (id) => fetch(id), { immediate: true })
fetchLibraries()
+9
View File
@@ -11,6 +11,7 @@ import ViewHeader from '@/components/ViewHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import { useBookQuery, type BookCard } from '@/features/book/composables/useBookQuery'
import { useBookEvents } from '@/features/book/composables/useBookEvents'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
@@ -29,6 +30,14 @@ 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()
onBookMissing((bookIds) => {
const missing = new Set(bookIds)
for (const book of books.value) {
if (missing.has(book.id)) book.status = 'missing'
}
})
const filterOpen = ref(false)
const SORT_FIELD_LABELS: Record<SortField, string> = {
+11
View File
@@ -21,3 +21,14 @@ export interface CoverRefreshedEvent {
bookId: number;
libraryId: number;
}
export interface BookMissingEvent {
libraryId: number;
bookIds: number[];
}
export interface BookFileRemovedEvent {
libraryId: number;
bookId: number;
fileId: number;
}
+20 -7
View File
@@ -16,6 +16,7 @@
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"scan:test": "ts-node -r tsconfig-paths/register src/scripts/scan-test.ts",
"sim:missing": "ts-node -r tsconfig-paths/register src/scripts/missing-detection-sim.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
@@ -40,14 +41,14 @@
"@nestjs/websockets": "^11.1.12",
"@projectx/types": "workspace:*",
"bcryptjs": "^3.0.3",
"chokidar": "^4.0.3",
"chokidar": "5.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"drizzle-orm": "^0.45.1",
"fast-xml-parser": "^5.3.3",
"jose": "^6.1.3",
"node-unrar-js": "^2.0.2",
"nodemailer": "^7.0.12",
"nodemailer": "^7.0.13",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdf-lib": "^1.17.1",
@@ -57,7 +58,7 @@
"sharp": "^0.34.5",
"socket.io": "^4.8.3",
"unzipper": "^0.12.3",
"zod": "^4.3.5"
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
@@ -66,14 +67,13 @@
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.7.10",
"@swc/core": "^1.15.10",
"@swc/core": "^1.15.11",
"@types/bcrypt": "^6.0.0",
"@types/bcryptjs": "^3.0.0",
"@types/chokidar": "^1.7.5",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/nodemailer": "^7.0.5",
"@types/nodemailer": "^7.0.9",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.16.0",
"@types/supertest": "^6.0.2",
@@ -104,7 +104,20 @@
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
"^.+\\.(t|j)s$": [
"ts-jest",
{
"tsconfig": {
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"resolvePackageJsonExports": false
}
}
]
},
"moduleNameMapper": {
"^@projectx/types$": "<rootDir>/../../packages/types/src/index.ts"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1769537055823,
"when": 1769655184878,
"tag": "0000_baseline",
"breakpoints": true
}
@@ -0,0 +1,110 @@
import { FileEventProcessorService } from './file-event-processor.service';
import { ScannerRepository } from './scanner.repository';
const mockRepo: jest.Mocked<
Pick<
ScannerRepository,
| 'findBookFileByAbsolutePath'
| 'deleteBookFile'
| 'countBookFilesByBookId'
| 'markBooksAsMissing'
| 'findBooksByFolderPath'
| 'deleteBookFilesByBookIds'
>
> = {
findBookFileByAbsolutePath: jest.fn(),
deleteBookFile: jest.fn(),
countBookFilesByBookId: jest.fn(),
markBooksAsMissing: jest.fn(),
findBooksByFolderPath: jest.fn(),
deleteBookFilesByBookIds: jest.fn(),
};
function makeService() {
return new FileEventProcessorService(mockRepo as unknown as ScannerRepository);
}
beforeEach(() => {
jest.clearAllMocks();
mockRepo.deleteBookFile.mockResolvedValue(undefined);
mockRepo.markBooksAsMissing.mockResolvedValue(undefined);
mockRepo.deleteBookFilesByBookIds.mockResolvedValue(undefined);
});
// ── handleUnlink ──────────────────────────────────────────────────────────────
describe('handleUnlink', () => {
it('returns noop when path is not tracked in DB', async () => {
mockRepo.findBookFileByAbsolutePath.mockResolvedValue(null);
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 () => {
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] });
});
});
// ── handleUnlinkDir ───────────────────────────────────────────────────────────
describe('handleUnlinkDir', () => {
it('returns noop when no books are under the deleted folder', async () => {
mockRepo.findBooksByFolderPath.mockResolvedValue([]);
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 () => {
mockRepo.findBooksByFolderPath.mockResolvedValue([
{ id: 30, libraryId: 2 },
{ id: 31, libraryId: 2 },
] as any);
const result = await makeService().handleUnlinkDir('/books/Series');
expect(mockRepo.deleteBookFilesByBookIds).toHaveBeenCalledWith([30, 31]);
expect(mockRepo.markBooksAsMissing).toHaveBeenCalledWith([30, 31]);
expect(result).toEqual({ type: 'book-missing', libraryId: 2, bookIds: [30, 31] });
});
});
@@ -0,0 +1,47 @@
import { Injectable, Logger } from '@nestjs/common';
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: 'noop' };
@Injectable()
export class FileEventProcessorService {
private readonly logger = new Logger(FileEventProcessorService.name);
constructor(private readonly scannerRepo: ScannerRepository) {}
async handleUnlink(absolutePath: string): Promise<FileEventResult> {
const row = await this.scannerRepo.findBookFileByAbsolutePath(absolutePath);
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}`);
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' };
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 };
}
}
@@ -0,0 +1,104 @@
import { FileWatcherService } from './file-watcher.service';
import { FileEventProcessorService } from './file-event-processor.service';
import { ScanGateway } from './scan.gateway';
function makeService(processorResult: any = { type: 'noop' }) {
const processor = {
handleUnlink: jest.fn().mockResolvedValue(processorResult),
handleUnlinkDir: jest.fn().mockResolvedValue(processorResult),
} as unknown as FileEventProcessorService;
const gateway = {
emitBookMissing: jest.fn(),
emitBookFileRemoved: 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 };
}
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
// ── process() routing ─────────────────────────────────────────────────────────
describe('process()', () => {
it('calls emitBookMissing when processor returns book-missing', async () => {
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');
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 () => {
const missing = { type: 'book-missing', libraryId: 3, bookIds: [20] };
const { service, processor, gateway } = makeService(missing);
processor.handleUnlinkDir = jest.fn().mockResolvedValue(missing);
await (service as any).process('unlinkDir', '/books/Author');
expect(processor.handleUnlinkDir).toHaveBeenCalledWith('/books/Author');
expect(processor.handleUnlink).not.toHaveBeenCalled();
expect(gateway.emitBookMissing).toHaveBeenCalledWith({ libraryId: 3, bookIds: [20] });
});
});
// ── schedule() debounce ───────────────────────────────────────────────────────
describe('schedule() debounce', () => {
it('debounces rapid events for the same path — process called only once', async () => {
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
jest.runAllTimers();
await Promise.resolve(); // flush microtasks
expect(processSpy).toHaveBeenCalledTimes(1);
expect(processSpy).toHaveBeenCalledWith('unlink', '/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');
jest.runAllTimers();
await Promise.resolve();
expect(processSpy).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,89 @@
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 { DB } from '../../db';
import * as schema from '../../db/schema';
import { libraries, libraryFolders } from '../../db/schema';
import { ScanGateway } from './scan.gateway';
import { FileEventProcessorService } from './file-event-processor.service';
type Db = NodePgDatabase<typeof schema>;
const DEBOUNCE_MS = 500;
@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>>();
constructor(
@Inject(DB) private readonly db: Db,
private readonly processor: FileEventProcessorService,
private readonly gateway: ScanGateway,
) {}
async onApplicationBootstrap(): Promise<void> {
const watchedLibraries = await this.db.select().from(libraries).where(eq(libraries.watch, true));
for (const lib of watchedLibraries) {
const folders = await this.db.select().from(libraryFolders).where(eq(libraryFolders.libraryId, lib.id));
await this.startWatcher(
lib.id,
folders.map((f) => f.path),
);
}
}
async onModuleDestroy(): Promise<void> {
for (const timer of this.pendingTimers.values()) clearTimeout(timer);
this.pendingTimers.clear();
for (const watcher of this.watchers.values()) await watcher.close();
this.watchers.clear();
}
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 });
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}`));
this.watchers.set(libraryId, watcher);
this.logger.log(`Watching ${paths.length} folder(s) for library ${libraryId}`);
}
async stopWatcher(libraryId: number): Promise<void> {
const existing = this.watchers.get(libraryId);
if (!existing) return;
await existing.close();
this.watchers.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);
this.process(type, path).catch((err) => this.logger.error(`Failed to process ${type} for ${path}: ${(err as Error).message}`));
}, DEBOUNCE_MS),
);
}
private async process(type: 'unlink' | 'unlinkDir', path: string): Promise<void> {
const result = type === 'unlink' ? await this.processor.handleUnlink(path) : 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 });
}
}
}
@@ -0,0 +1,105 @@
import { ScanGateway } from './scan.gateway';
import type { BookFileRemovedEvent, BookMissingEvent, CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types';
function makeGateway() {
const gateway = new ScanGateway(
{ verify: jest.fn() } as any,
{ validateUser: jest.fn() } as any,
{ get: jest.fn(), isRunning: jest.fn() } as any,
);
return gateway;
}
function mockServer() {
const emit = jest.fn();
const to = jest.fn().mockReturnValue({ emit });
return { server: { to, emit }, to, emit };
}
// ── book:missing ──────────────────────────────────────────────────────────────
describe('emitBookMissing', () => {
it('emits book:missing to the correct library room', () => {
const gateway = makeGateway();
const { server, to, emit } = mockServer();
gateway['server'] = server as any;
const event: BookMissingEvent = { libraryId: 7, bookIds: [1, 2, 3] };
gateway.emitBookMissing(event);
expect(to).toHaveBeenCalledWith('library:7');
expect(emit).toHaveBeenCalledWith('book:missing', event);
});
it('does not throw when server is undefined (application context)', () => {
const gateway = makeGateway();
gateway['server'] = undefined as any;
expect(() => gateway.emitBookMissing({ libraryId: 1, bookIds: [1] })).not.toThrow();
});
});
// ── 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', () => {
it('emits scan:progress to the correct room', () => {
const gateway = makeGateway();
const { server, to, emit } = mockServer();
gateway['server'] = server as any;
const event: ScanProgressEvent = {
jobId: 1, libraryId: 4, status: 'running',
processed: 10, total: 20, added: 5, updated: 2, missing: 0,
};
gateway.emitProgress(event);
expect(to).toHaveBeenCalledWith('library:4');
expect(emit).toHaveBeenCalledWith('scan:progress', event);
});
it('does not throw when server is undefined', () => {
const gateway = makeGateway();
gateway['server'] = undefined as any;
expect(() =>
gateway.emitProgress({ jobId: 1, libraryId: 1, status: 'running', processed: 0, total: 0, added: 0, updated: 0, missing: 0 }),
).not.toThrow();
});
});
describe('emitCoverRefreshed', () => {
it('emits cover:refreshed to the correct room', () => {
const gateway = makeGateway();
const { server, to, emit } = mockServer();
gateway['server'] = server as any;
const event: CoverRefreshedEvent = { bookId: 9, libraryId: 2 };
gateway.emitCoverRefreshed(event);
expect(to).toHaveBeenCalledWith('library:2');
expect(emit).toHaveBeenCalledWith('cover:refreshed', event);
});
});
+14 -5
View File
@@ -3,7 +3,7 @@ import { JwtService } from '@nestjs/jwt';
import { OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import type { CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types';
import type { BookFileRemovedEvent, BookMissingEvent, CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types';
import { AuthService } from '../auth/auth.service';
import { ScanJobStore } from './scan-job-store.service';
@@ -57,16 +57,25 @@ export class ScanGateway implements OnGatewayConnection, OnGatewayDisconnect {
}
}
// Called by ScannerService — not a WS message handler.
// Called by services — not WS message handlers. Guards against script/test
// contexts where no HTTP adapter starts and this.server is never populated.
emitProgress(event: ScanProgressEvent): void {
this.server.to(`library:${event.libraryId}`).emit('scan:progress', event);
this.server?.to(`library:${event.libraryId}`).emit('scan:progress', event);
}
emitCoverRefreshProgress(event: CoverRefreshProgressEvent): void {
this.server.to(`library:${event.libraryId}`).emit('cover:refresh:progress', event);
this.server?.to(`library:${event.libraryId}`).emit('cover:refresh:progress', event);
}
emitCoverRefreshed(event: CoverRefreshedEvent): void {
this.server.to(`library:${event.libraryId}`).emit('cover:refreshed', event);
this.server?.to(`library:${event.libraryId}`).emit('cover:refreshed', event);
}
emitBookMissing(event: BookMissingEvent): void {
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);
}
}
+3 -1
View File
@@ -4,6 +4,8 @@ import { JwtModule } from '@nestjs/jwt';
import { AuthModule } from '../auth/auth.module';
import { MetadataModule } from '../metadata/metadata.module';
import { FileEventProcessorService } from './file-event-processor.service';
import { FileWatcherService } from './file-watcher.service';
import { ScanGateway } from './scan.gateway';
import { ScanJobStore } from './scan-job-store.service';
import { ScannerController } from './scanner.controller';
@@ -23,7 +25,7 @@ import { ScannerService } from './scanner.service';
}),
],
controllers: [ScannerController],
providers: [ScannerService, ScannerRepository, ScanGateway, ScanJobStore],
providers: [ScannerService, ScannerRepository, ScanGateway, ScanJobStore, FileEventProcessorService, FileWatcherService],
exports: [ScannerService],
})
export class ScannerModule {}
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, inArray, ne } from 'drizzle-orm';
import { and, count, eq, inArray, like, ne, or } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -108,4 +108,35 @@ export class ScannerRepository {
.returning();
return file!;
}
async findBookFileByAbsolutePath(absolutePath: string) {
const [row] = await this.db
.select({ file: bookFiles, libraryId: books.libraryId })
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(eq(bookFiles.absolutePath, absolutePath))
.limit(1);
return row ?? null;
}
async deleteBookFile(id: number) {
await this.db.delete(bookFiles).where(eq(bookFiles.id, id));
}
async countBookFilesByBookId(bookId: number): Promise<number> {
const [result] = await this.db.select({ count: count() }).from(bookFiles).where(eq(bookFiles.bookId, bookId));
return result?.count ?? 0;
}
async findBooksByFolderPath(folderPath: string) {
return this.db
.select()
.from(books)
.where(or(eq(books.folderPath, folderPath), like(books.folderPath, folderPath + '/%')));
}
async deleteBookFilesByBookIds(bookIds: number[]) {
if (bookIds.length === 0) return;
await this.db.delete(bookFiles).where(inArray(bookFiles.bookId, bookIds));
}
}
@@ -1,6 +1,6 @@
import { ConflictException, Injectable, Logger, NotFoundException, OnApplicationBootstrap } from '@nestjs/common';
import type { CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types';
import type { BookMissingEvent, CoverRefreshedEvent, CoverRefreshProgressEvent, ScanProgressEvent } from '@projectx/types';
import { MetadataService } from '../metadata/metadata.service';
import { ScanGateway } from './scan.gateway';
import { ScanJobStore } from './scan-job-store.service';
@@ -187,6 +187,7 @@ export class ScannerService implements OnApplicationBootstrap {
await this.scannerRepo.markBooksAsMissing(missingIds);
counts.missingCount += missingIds.length;
this.scanJobStore.increment(libraryId, { missing: missingIds.length });
this.scanGateway.emitBookMissing({ libraryId, bookIds: missingIds } satisfies BookMissingEvent);
}
return counts;