mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(scanner): detect missing books via watcher and propagate to UI
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user