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> = {