feat(cover,metadata): ffprobe extraction, cover placeholders, grid refresh, and audiobook search

- Replace music-metadata and mediainfo with @ffprobe-installer for all audio metadata extraction; remove mediainfo dependency
- Show BookCoverPlaceholder in all missing-cover locations: Similar/Series/Author Books carousel, cover editor panel, metadata search result cards, and metadata diff panel
- Extend BookRecommendation and SeriesBookRecommendation shared types with hasCover and authors; update recommendation and book repositories to supply them via parallel queries
- Fix cover refresh not updating in grid: BookCoverCard now merges refreshed BookDetail into the local BookCard and emits update:book; VirtualBookGrid forwards the event; Home, Series, Author, and Collection views wire the handler so covers update without a page reload
- Add audiobook covers checkbox to the Online Search drawer so users can toggle between square and portrait cover searches; result thumbnails and loading skeletons adapt their aspect ratio to match the selection
This commit is contained in:
neon
2026-05-10 23:14:11 +00:00
parent ea998b8bd2
commit 57d91ebea1
25 changed files with 1491 additions and 494 deletions
@@ -129,6 +129,11 @@ function handleBookAction(book: BookCard, action: 'quick-view' | 'edit-metadata'
}
}
function handleBookUpdate(updated: BookCard) {
const idx = books.value.findIndex((b) => b.id === updated.id)
if (idx !== -1) books.value = books.value.map((b, i) => (i === idx ? updated : b))
}
function loadIfSentinelVisible() {
if (loadingBooks.value || !hasMore.value || !sentinel.value) return
if (sentinel.value.getBoundingClientRect().top < window.innerHeight + 250) {
@@ -546,6 +551,7 @@ watch(authorName, () => {
:cover-size="authorBookCoverSize"
:grid-gap="authorBookGridGap"
@action="handleBookAction"
@update:book="handleBookUpdate"
/>
<div v-if="viewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
@@ -40,6 +40,7 @@ import {
import { useCoverVersions } from '../composables/useCoverVersions'
import { useRefreshMetadata } from '../composables/useRefreshMetadata'
import { useRefreshingBooks } from '../composables/useRefreshingBooks'
import { mergeBookCardWithDetail } from '../lib/book-card-mapper'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
@@ -59,6 +60,7 @@ type BookActionType = 'quick-view' | 'add-to-collection' | 'delete'
const emit = defineEmits<{
action: [type: BookActionType]
select: [event: MouseEvent]
'update:book': [updated: BookCard]
}>()
const coverStyle = computed(() => bookCoverStyle(props.book.title ?? String(props.book.id)))
@@ -102,6 +104,11 @@ async function reExtractCover() {
reExtractingCover.value = false
}
}
async function handleRefreshMetadata() {
const updated = await refreshWithFeedback(props.book.id)
if (updated) emit('update:book', mergeBookCardWithDetail(props.book, updated))
}
const { hasPermission } = usePermissions()
const { cardOverlays } = useDisplaySettings()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
@@ -460,7 +467,7 @@ async function handleSetStatus(status: ReadStatus) {
<Pencil class="size-4 mr-2" />
Edit Metadata
</DropdownMenuItem>
<DropdownMenuItem :disabled="anyRefreshing" @click="refreshWithFeedback(book.id)">
<DropdownMenuItem :disabled="anyRefreshing" @click="handleRefreshMetadata">
<Loader2 v-if="anyRefreshing" class="size-4 mr-2 animate-spin" />
<RefreshCw v-else class="size-4 mr-2" />
Refresh Metadata
@@ -28,6 +28,7 @@ const props = withDefaults(
const emit = defineEmits<{
action: [book: BookCard, action: BookActionType]
select: [bookId: number, event: MouseEvent]
'update:book': [updated: BookCard]
}>()
const containerRef = ref<HTMLElement | null>(null)
@@ -104,6 +105,7 @@ const scrollerStyle = computed(() => ({
:selected="isSelected?.(book.id) ?? false"
@action="emit('action', book, $event)"
@select="emit('select', book.id, $event)"
@update:book="emit('update:book', $event)"
/>
</div>
</template>
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import BookCarousel, { type CarouselBook } from './BookCarousel.vue'
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn<() => void>() }),
}))
vi.mock('@/features/book/composables/useCoverVersions', () => ({
useCoverVersions: () => ({
coverUrl: (id: number, size: string) => `/api/covers/${id}/${size}`,
}),
}))
vi.mock('@/features/book/components/BookCoverPlaceholder.vue', () => ({
default: defineComponent({
name: 'BookCoverPlaceholder',
props: ['title', 'authorLine', 'isAudio', 'seed'],
setup(props) {
return () => h('div', { 'data-testid': 'placeholder', 'data-title': props.title, 'data-author': props.authorLine }, [])
},
}),
}))
vi.mock('@/features/book/lib/book-cover', () => ({
bookCoverStyle: (seed: string) => ({ background: `linear-gradient(${seed})`, color: 'oklch(90% 0.05 200)' }),
}))
function makeBook(overrides: Partial<CarouselBook> = {}): CarouselBook {
return {
id: 1,
title: 'Dune',
hasCover: true,
authors: ['Frank Herbert'],
seriesIndex: null,
...overrides,
}
}
function mountCarousel(books: CarouselBook[], options: { loading?: boolean; showSeriesIndex?: boolean } = {}) {
return mount(BookCarousel, {
props: {
books,
loading: options.loading ?? false,
showSeriesIndex: options.showSeriesIndex ?? false,
},
global: {
stubs: { ChevronLeft: true, ChevronRight: true },
},
})
}
describe('BookCarousel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders nothing when not loading and books array is empty', () => {
const wrapper = mountCarousel([])
expect(wrapper.find('[data-book-id]').exists()).toBe(false)
})
it('renders loading skeletons when loading is true', () => {
const wrapper = mountCarousel([], { loading: true })
const skeletons = wrapper.findAll('.animate-shimmer')
expect(skeletons.length).toBe(10)
})
it('renders an img when book.hasCover is true', () => {
const book = makeBook({ hasCover: true })
const wrapper = mountCarousel([book])
expect(wrapper.find('img').exists()).toBe(true)
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(false)
})
it('renders BookCoverPlaceholder when book.hasCover is false', () => {
const book = makeBook({ hasCover: false, title: 'Unknown', authors: [] })
const wrapper = mountCarousel([book])
expect(wrapper.find('[data-testid="placeholder"]').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(false)
})
it('passes title to BookCoverPlaceholder', () => {
const book = makeBook({ hasCover: false, title: 'Foundation', authors: [] })
const wrapper = mountCarousel([book])
const placeholder = wrapper.find('[data-testid="placeholder"]')
expect(placeholder.attributes('data-title')).toBe('Foundation')
})
it('passes joined authors as authorLine to BookCoverPlaceholder', () => {
const book = makeBook({ hasCover: false, authors: ['Isaac Asimov', 'Robert Heinlein'] })
const wrapper = mountCarousel([book])
const placeholder = wrapper.find('[data-testid="placeholder"]')
expect(placeholder.attributes('data-author')).toBe('Isaac Asimov, Robert Heinlein')
})
it('passes null authorLine to BookCoverPlaceholder when authors array is empty', () => {
const book = makeBook({ hasCover: false, authors: [] })
const wrapper = mountCarousel([book])
const placeholder = wrapper.find('[data-testid="placeholder"]')
expect(placeholder.attributes('data-author')).toBeUndefined()
})
it('renders the correct cover image src when hasCover is true', () => {
const book = makeBook({ id: 42, hasCover: true })
const wrapper = mountCarousel([book])
expect(wrapper.find('img').attributes('src')).toBe('/api/covers/42/thumbnail')
})
it('shows series index badge when showSeriesIndex is true and seriesIndex is set', () => {
const book = makeBook({ seriesIndex: 3 })
const wrapper = mountCarousel([book], { showSeriesIndex: true })
expect(wrapper.find('span').text()).toContain('#3')
})
it('does not show series index badge when showSeriesIndex is false', () => {
const book = makeBook({ seriesIndex: 3 })
const wrapper = mountCarousel([book], { showSeriesIndex: false })
expect(wrapper.find('span').exists()).toBe(false)
})
it('does not show series index badge when seriesIndex is null', () => {
const book = makeBook({ seriesIndex: null })
const wrapper = mountCarousel([book], { showSeriesIndex: true })
expect(wrapper.find('span').exists()).toBe(false)
})
it('renders multiple books', () => {
const books = [makeBook({ id: 1, hasCover: true }), makeBook({ id: 2, hasCover: false }), makeBook({ id: 3, hasCover: true })]
const wrapper = mountCarousel(books)
expect(wrapper.findAll('[data-book-id]').length).toBe(3)
expect(wrapper.findAll('img').length).toBe(2)
expect(wrapper.findAll('[data-testid="placeholder"]').length).toBe(1)
})
it('uses book id as seed when title is null', () => {
const book = makeBook({ hasCover: false, title: null, id: 99 })
const wrapper = mountCarousel([book])
const placeholder = wrapper.find('[data-testid="placeholder"]')
expect(placeholder.exists()).toBe(true)
})
it('applies data-book-id attribute for scroll-into-view behaviour', () => {
const book = makeBook({ id: 77 })
const wrapper = mountCarousel([book])
expect(wrapper.find('[data-book-id="77"]').exists()).toBe(true)
})
})
@@ -5,11 +5,14 @@ import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import BookCoverPlaceholder from '@/features/book/components/BookCoverPlaceholder.vue'
export interface CarouselBook {
id: number
title: string | null
seriesIndex?: number | null
hasCover: boolean
authors: string[]
}
const props = withDefaults(
@@ -100,12 +103,20 @@ defineExpose({ scroll })
:style="bookCoverStyle(book.title ?? String(book.id))"
>
<img
v-if="book.hasCover"
:src="coverUrl(book.id, 'thumbnail')"
:alt="book.title ?? ''"
class="w-full h-full object-cover transition-opacity duration-300 ease-out"
loading="lazy"
@error="(e) => ((e.target as HTMLImageElement).style.display = 'none')"
/>
<BookCoverPlaceholder
v-else
:title="book.title"
:author-line="book.authors.length > 0 ? book.authors.join(', ') : null"
:is-audio="false"
:seed="book.title ?? String(book.id)"
/>
<span
v-if="showSeriesIndex && book.seriesIndex != null"
class="absolute bottom-1.5 left-1.5 bg-black/60 text-white text-[9px] font-semibold leading-none px-1.5 py-1 rounded-full pointer-events-none"
@@ -8,6 +8,7 @@ import { useCoverEditor } from '../../../composables/useCoverEditor'
import { useCoverVersions } from '../../../composables/useCoverVersions'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
import BookCoverPlaceholder from '@/features/book/components/BookCoverPlaceholder.vue'
import CoverSearchDrawer from './CoverSearchDrawer.vue'
const props = defineProps<{ book: BookDetail; locked?: boolean }>()
@@ -43,6 +44,8 @@ const activeSrc = computed(() => previewSrc.value ?? coverUrl(props.book.id, 'co
const hasPending = computed(() => !!pendingFile.value || !!pendingUrl.value)
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const hasCover = computed(() => !!props.book.coverSource || !!previewSrc.value)
const coverSeed = computed(() => props.book.title ?? props.book.folderPath.split('/').pop() ?? String(props.book.id))
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
function cancelPending() {
@@ -97,11 +100,19 @@ onUnmounted(() => clearTimeout(debounceTimer))
<div class="flex flex-row gap-5 md:gap-3 lg:flex-col">
<!-- Cover image -->
<div
class="relative w-36 shrink-0 lg:w-full overflow-hidden rounded-lg bg-muted shadow-md cursor-zoom-in"
class="relative w-36 shrink-0 lg:w-full overflow-hidden rounded-lg bg-muted shadow-md"
:class="hasCover ? 'cursor-zoom-in' : ''"
:style="{ aspectRatio: coverAspectRatio }"
@click="lightboxOpen = true"
@click="hasCover ? (lightboxOpen = true) : undefined"
>
<img :src="activeSrc" :alt="book.title ?? ''" class="w-full h-full object-contain" @error="hideOnError" />
<img v-if="hasCover" :src="activeSrc" :alt="book.title ?? ''" class="w-full h-full object-contain" @error="hideOnError" />
<BookCoverPlaceholder
v-else
:title="book.title"
:author-line="book.authors.map((a) => a.name).join(', ') || null"
:is-audio="isPrimaryAudio"
:seed="coverSeed"
/>
<button
type="button"
class="absolute bottom-2 right-2 flex items-center justify-center size-6 rounded-md bg-background/90 shadow-sm border border-input hover:bg-muted transition-colors"
@@ -115,7 +126,11 @@ onUnmounted(() => clearTimeout(debounceTimer))
<!-- Lightbox -->
<Teleport to="body">
<div v-if="lightboxOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" @click="lightboxOpen = false">
<div
v-if="lightboxOpen && hasCover"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
@click="lightboxOpen = false"
>
<button
class="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
@click="lightboxOpen = false"
@@ -0,0 +1,346 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import CoverSearchDrawer from './CoverSearchDrawer.vue'
vi.mock('@/components/ui/sheet', () => ({
Sheet: defineComponent({
name: 'MockSheet',
props: ['open'],
setup(_, { slots }) {
return () => h('div', { 'data-testid': 'sheet' }, slots.default?.())
},
}),
SheetContent: defineComponent({
name: 'MockSheetContent',
setup(_, { slots }) {
return () => h('div', { 'data-testid': 'sheet-content' }, slots.default?.())
},
}),
SheetHeader: defineComponent({
name: 'MockSheetHeader',
setup(_, { slots }) {
return () => h('div', slots.default?.())
},
}),
SheetTitle: defineComponent({
name: 'MockSheetTitle',
setup(_, { slots }) {
return () => h('h2', slots.default?.())
},
}),
SheetDescription: defineComponent({
name: 'MockSheetDescription',
setup(_, { slots }) {
return () => h('p', slots.default?.())
},
}),
}))
vi.mock('lucide-vue-next', () => ({
Search: defineComponent({ name: 'MockSearch', setup: () => () => h('span', { 'data-testid': 'icon-search' }) }),
Loader2: defineComponent({ name: 'MockLoader', setup: () => () => h('span', { 'data-testid': 'icon-loader' }) }),
Image: defineComponent({ name: 'MockImageIcon', setup: () => () => h('span') }),
Check: defineComponent({ name: 'MockCheck', setup: () => () => h('span', { 'data-testid': 'icon-check' }) }),
}))
type CoverResult = { url: string; previewUrl: string; sourceUrl: string; width: number; height: number; source: string }
function makeCoverResult(url: string, width = 600, height = 600): CoverResult {
return { url, previewUrl: `${url}?thumb`, sourceUrl: url, width, height, source: 'iTunes' }
}
function okResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200 })
}
function mountDrawer(props: { open?: boolean; initialTitle?: string; initialAuthor?: string; isAudiobook?: boolean } = {}) {
return mount(CoverSearchDrawer, {
props: {
open: props.open ?? true,
initialTitle: props.initialTitle ?? 'Dune',
initialAuthor: props.initialAuthor ?? 'Frank Herbert',
isAudiobook: props.isAudiobook ?? false,
},
})
}
type DrawerVm = {
performSearch: () => Promise<void>
resultAspectClass: string
searchResults: CoverResult[]
handleSelect: (url: string) => void
handleOpenChange: (val: boolean) => void
}
describe('CoverSearchDrawer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('audiobook checkbox', () => {
it('defaults checkbox to false for regular books', () => {
const wrapper = mountDrawer({ isAudiobook: false })
const checkbox = wrapper.find('input[type="checkbox"]')
expect(checkbox.exists()).toBe(true)
expect((checkbox.element as HTMLInputElement).checked).toBe(false)
})
it('defaults checkbox to true for audiobooks', () => {
const wrapper = mountDrawer({ isAudiobook: true })
const checkbox = wrapper.find('input[type="checkbox"]')
expect((checkbox.element as HTMLInputElement).checked).toBe(true)
})
it('displays the audiobook label and square hint', () => {
const wrapper = mountDrawer()
expect(wrapper.text()).toContain('Audiobook covers')
expect(wrapper.text()).toContain('square')
})
it('toggles isAudiobookSearch when checkbox is changed', async () => {
const wrapper = mountDrawer({ isAudiobook: false })
const checkbox = wrapper.find('input[type="checkbox"]')
await checkbox.trigger('change')
expect((checkbox.element as HTMLInputElement).checked).toBe(true)
})
it('can be toggled back to false', async () => {
const wrapper = mountDrawer({ isAudiobook: true })
const checkbox = wrapper.find('input[type="checkbox"]')
await checkbox.trigger('change')
expect((checkbox.element as HTMLInputElement).checked).toBe(false)
})
it('resets checkbox to prop value when drawer reopens', async () => {
const wrapper = mountDrawer({ isAudiobook: false })
const checkbox = wrapper.find('input[type="checkbox"]')
await checkbox.trigger('change')
expect((checkbox.element as HTMLInputElement).checked).toBe(true)
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
expect((checkbox.element as HTMLInputElement).checked).toBe(false)
})
})
describe('aspect ratio', () => {
it('uses portrait aspect class for regular books', () => {
const wrapper = mountDrawer({ isAudiobook: false })
const vm = wrapper.vm as unknown as DrawerVm
expect(vm.resultAspectClass).toBe('aspect-[2/3]')
})
it('uses square aspect class for audiobooks', () => {
const wrapper = mountDrawer({ isAudiobook: true })
const vm = wrapper.vm as unknown as DrawerVm
expect(vm.resultAspectClass).toBe('aspect-square')
})
it('toggling checkbox to audiobook updates resultAspectClass to square', async () => {
const wrapper = mountDrawer({ isAudiobook: false })
const vm = wrapper.vm as unknown as DrawerVm
expect(vm.resultAspectClass).toBe('aspect-[2/3]')
await wrapper.find('input[type="checkbox"]').trigger('change')
expect(vm.resultAspectClass).toBe('aspect-square')
})
it('toggling checkbox back resets resultAspectClass to portrait', async () => {
const wrapper = mountDrawer({ isAudiobook: true })
const vm = wrapper.vm as unknown as DrawerVm
expect(vm.resultAspectClass).toBe('aspect-square')
await wrapper.find('input[type="checkbox"]').trigger('change')
expect(vm.resultAspectClass).toBe('aspect-[2/3]')
})
it('resultAspectClass stays square when searching with audiobook results', async () => {
const results = [makeCoverResult('https://img/a'), makeCoverResult('https://img/b')]
vi.stubGlobal('fetch', () => Promise.resolve(okResponse(results)))
const wrapper = mountDrawer({ isAudiobook: true })
const vm = wrapper.vm as unknown as DrawerVm
await vm.performSearch()
await flushPromises()
expect(vm.resultAspectClass).toBe('aspect-square')
expect(vm.searchResults).toHaveLength(2)
})
})
describe('API call parameters', () => {
it('sends isAudiobook=false when checkbox is unchecked', async () => {
let capturedUrl = ''
vi.stubGlobal('fetch', (url: string) => {
capturedUrl = url
return Promise.resolve(okResponse([]))
})
const wrapper = mountDrawer({ isAudiobook: false })
await (wrapper.vm as unknown as DrawerVm).performSearch()
await flushPromises()
expect(capturedUrl).toContain('isAudiobook=false')
})
it('sends isAudiobook=true when checkbox is checked', async () => {
let capturedUrl = ''
vi.stubGlobal('fetch', (url: string) => {
capturedUrl = url
return Promise.resolve(okResponse([]))
})
const wrapper = mountDrawer({ isAudiobook: true })
await (wrapper.vm as unknown as DrawerVm).performSearch()
await flushPromises()
expect(capturedUrl).toContain('isAudiobook=true')
})
it('sends isAudiobook=true after toggling checkbox on a regular book', async () => {
let capturedUrl = ''
vi.stubGlobal('fetch', (url: string) => {
capturedUrl = url
return Promise.resolve(okResponse([]))
})
const wrapper = mountDrawer({ isAudiobook: false })
await wrapper.find('input[type="checkbox"]').trigger('change')
await (wrapper.vm as unknown as DrawerVm).performSearch()
await flushPromises()
expect(capturedUrl).toContain('isAudiobook=true')
})
it('sends isAudiobook=false after unchecking checkbox on an audiobook', async () => {
let capturedUrl = ''
vi.stubGlobal('fetch', (url: string) => {
capturedUrl = url
return Promise.resolve(okResponse([]))
})
const wrapper = mountDrawer({ isAudiobook: true })
await wrapper.find('input[type="checkbox"]').trigger('change')
await (wrapper.vm as unknown as DrawerVm).performSearch()
await flushPromises()
expect(capturedUrl).toContain('isAudiobook=false')
})
it('includes title and author in the search params', async () => {
let capturedUrl = ''
vi.stubGlobal('fetch', (url: string) => {
capturedUrl = url
return Promise.resolve(okResponse([]))
})
const wrapper = mountDrawer({ initialTitle: 'Dune', initialAuthor: 'Frank Herbert' })
await (wrapper.vm as unknown as DrawerVm).performSearch()
await flushPromises()
expect(capturedUrl).toContain('title=Dune')
expect(capturedUrl).toContain('author=Frank+Herbert')
})
it('includes the selected provider in the search params', async () => {
let capturedUrl = ''
vi.stubGlobal('fetch', (url: string) => {
capturedUrl = url
return Promise.resolve(okResponse([]))
})
const wrapper = mountDrawer()
await wrapper.find('select').setValue('itunes')
await (wrapper.vm as unknown as DrawerVm).performSearch()
await flushPromises()
expect(capturedUrl).toContain('provider=itunes')
})
it('does not call fetch when title is empty', async () => {
const fetchMock = vi.fn<() => Promise<Response>>()
vi.stubGlobal('fetch', fetchMock)
const wrapper = mountDrawer({ initialTitle: '' })
await (wrapper.vm as unknown as DrawerVm).performSearch()
expect(fetchMock).not.toHaveBeenCalled()
})
})
describe('search results', () => {
it('populates searchResults from API response', async () => {
const results = [makeCoverResult('https://img/a'), makeCoverResult('https://img/b')]
vi.stubGlobal('fetch', () => Promise.resolve(okResponse(results)))
const wrapper = mountDrawer()
const vm = wrapper.vm as unknown as DrawerVm
await vm.performSearch()
await flushPromises()
expect(vm.searchResults).toHaveLength(2)
expect(vm.searchResults[0]?.url).toBe('https://img/a')
})
it('clears previous results before a new search', async () => {
const first = [makeCoverResult('https://img/a')]
const second = [makeCoverResult('https://img/b'), makeCoverResult('https://img/c')]
const fetchStub = vi.fn<() => Promise<Response>>()
fetchStub.mockResolvedValueOnce(okResponse(first)).mockResolvedValueOnce(okResponse(second))
vi.stubGlobal('fetch', fetchStub)
const wrapper = mountDrawer()
const vm = wrapper.vm as unknown as DrawerVm
await vm.performSearch()
await flushPromises()
expect(vm.searchResults).toHaveLength(1)
await vm.performSearch()
await flushPromises()
expect(vm.searchResults).toHaveLength(2)
})
it('leaves searchResults empty and logs on fetch failure', async () => {
vi.stubGlobal('fetch', () => Promise.resolve(new Response('', { status: 500 })))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const wrapper = mountDrawer()
const vm = wrapper.vm as unknown as DrawerVm
await vm.performSearch()
await flushPromises()
expect(vm.searchResults).toHaveLength(0)
expect(consoleSpy).toHaveBeenCalled()
})
it('emits select and closes drawer when handleSelect is called', async () => {
const results = [makeCoverResult('https://img/a')]
vi.stubGlobal('fetch', () => Promise.resolve(okResponse(results)))
const wrapper = mountDrawer()
const vm = wrapper.vm as unknown as DrawerVm
await vm.performSearch()
await flushPromises()
vm.handleSelect('https://img/a')
expect(wrapper.emitted('select')).toEqual([['https://img/a']])
expect(wrapper.emitted('update:open')).toEqual([[false]])
})
it('emits update:open when handleOpenChange is called with false', () => {
const wrapper = mountDrawer()
const vm = wrapper.vm as unknown as DrawerVm
vm.handleOpenChange(false)
expect(wrapper.emitted('update:open')).toEqual([[false]])
})
})
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { Search, Loader2, Image as ImageIcon, Check } from 'lucide-vue-next'
import type { CoverSearchResult } from '@bookorbit/types'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
@@ -19,22 +19,30 @@ const emit = defineEmits<{
const searchTitle = ref(props.initialTitle)
const searchAuthor = ref(props.initialAuthor)
const searchProvider = ref<'duckduckgo' | 'itunes' | 'all'>('duckduckgo')
const isAudiobookSearch = ref(props.isAudiobook)
const isSearching = ref(false)
const searchResults = ref<CoverSearchResult[]>([])
const hasSearched = ref(false)
const resultAspectClass = computed(() => (isAudiobookSearch.value ? 'aspect-square' : 'aspect-[2/3]'))
watch(
() => props.open,
(val) => {
if (val) {
searchTitle.value = props.initialTitle
searchAuthor.value = props.initialAuthor
isAudiobookSearch.value = props.isAudiobook
searchResults.value = []
hasSearched.value = false
}
},
)
function toggleAudiobookSearch() {
isAudiobookSearch.value = !isAudiobookSearch.value
}
async function performSearch() {
if (!searchTitle.value.trim()) return
isSearching.value = true
@@ -44,7 +52,7 @@ async function performSearch() {
const params = new URLSearchParams({
title: searchTitle.value.trim(),
author: searchAuthor.value.trim(),
isAudiobook: String(props.isAudiobook),
isAudiobook: String(isAudiobookSearch.value),
provider: searchProvider.value,
})
const res = await fetch(`/api/v1/books/cover/search?${params}`)
@@ -125,6 +133,22 @@ function handleOpenChange(val: boolean) {
</button>
</div>
</div>
<!-- Audiobook cover toggle -->
<label class="flex items-center gap-2 cursor-pointer select-none w-fit">
<input
type="checkbox"
class="w-3.5 h-3.5 shrink-0"
style="accent-color: var(--primary)"
:checked="isAudiobookSearch"
@change="toggleAudiobookSearch"
/>
<span class="text-xs text-muted-foreground">
Audiobook covers
<span class="text-[10px] font-semibold text-primary/70 ml-0.5">(square)</span>
</span>
</label>
<button
class="hidden md:flex items-center justify-center gap-2 w-full h-10 rounded-lg bg-primary text-primary-foreground text-sm font-semibold hover:bg-primary/90 transition-all shadow-sm active:scale-[0.98]"
:disabled="isSearching"
@@ -139,14 +163,17 @@ function handleOpenChange(val: boolean) {
<!-- Results -->
<div class="flex-1 overflow-y-auto p-4">
<div v-if="isSearching" class="grid grid-cols-3 gap-4">
<div v-for="i in 9" :key="i" class="aspect-[2/3] rounded-lg bg-muted animate-pulse" />
<div v-for="i in 9" :key="i" :class="[resultAspectClass, 'rounded-lg bg-muted animate-pulse']" />
</div>
<div v-else-if="searchResults.length > 0" class="grid grid-cols-3 gap-4">
<div
v-for="res in searchResults"
:key="String(res.url)"
class="group relative aspect-[2/3] rounded-lg overflow-hidden bg-muted border border-border/50 hover:border-primary/50 hover:ring-4 hover:ring-primary/10 transition-all cursor-pointer"
:class="[
resultAspectClass,
'group relative rounded-lg overflow-hidden bg-muted border border-border/50 hover:border-primary/50 hover:ring-4 hover:ring-primary/10 transition-all cursor-pointer',
]"
@click="handleSelect(String(res.url))"
>
<img :src="res.previewUrl" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" loading="lazy" />
@@ -192,7 +219,7 @@ function handleOpenChange(val: boolean) {
<!-- Footer -->
<div class="p-4 border-t bg-muted/10 shrink-0">
<p class="text-[10px] text-center text-muted-foreground flex items-center justify-center gap-1">
<p class="text-[11px] text-center text-muted-foreground flex items-center justify-center gap-1">
<ImageIcon class="size-3" />
Tip: High resolution covers are marked in green
</p>
@@ -99,6 +99,11 @@ const { fields, picksPerProvider, toggleField, pickFieldFromProvider, clearPicks
const activeProviderLabel = computed(() => getProviderLabel(activeProvider.value, props.providers))
const resultsForActiveProvider = computed(() => props.filteredResults.filter((candidate) => candidate.provider === activeProvider.value))
const bookSeed = computed(() => props.current.title ?? 'Untitled')
const bookAuthorLine = computed(() => props.current.authors.join(', ') || null)
const candidateSeed = computed(() => activeCandidate.value.title ?? 'Untitled')
const candidateAuthorLine = computed(() => activeCandidate.value.authors?.join(', ') || null)
const selectedFieldCount = computed(() => {
let total = 0
for (const count of picksPerProvider.value.values()) {
@@ -352,6 +357,10 @@ onBeforeUnmount(() => {
:field="field"
:active-provider="activeProvider"
:providers="providers"
:book-seed="bookSeed"
:book-author-line="bookAuthorLine"
:candidate-seed="candidateSeed"
:candidate-author-line="candidateAuthorLine"
@toggle="toggleField"
@pick-from-provider="handlePickFromProvider"
/>
@@ -6,11 +6,16 @@ import type { DiffField, DiffFieldKey } from '../../../composables/useMetadataDi
import { hideOnError, providerBadgeStyle } from '../../../lib/metadata-fetch'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
import BookCoverPlaceholder from '@/features/book/components/BookCoverPlaceholder.vue'
const props = defineProps<{
field: DiffField
activeProvider: MetadataProviderKey
providers: MetadataProviderInfo[]
bookSeed?: string
bookAuthorLine?: string | null
candidateSeed?: string
candidateAuthorLine?: string | null
}>()
const emit = defineEmits<{
@@ -113,7 +118,9 @@ watch(
<img :src="field.bookValue" alt="" aria-hidden="true" class="absolute inset-0 w-full h-full object-cover scale-110 blur-md brightness-75" />
<img :src="field.bookValue" alt="Current cover" class="relative w-full h-full object-contain" @error="hideOnError" />
</template>
<div v-else class="w-full h-full bg-linear-to-br from-muted to-muted-foreground/10" />
<div v-else class="absolute inset-0">
<BookCoverPlaceholder :title="bookSeed ?? null" :author-line="bookAuthorLine ?? null" :is-audio="false" :seed="bookSeed ?? 'book'" />
</div>
<div
v-if="field.isPicked || field.bookValue"
class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
@@ -205,7 +212,14 @@ watch(
/>
<img :src="field.candidateDisplay" alt="New cover" class="relative w-full h-full object-contain" @error="hideOnError" />
</template>
<div v-else class="w-full h-full bg-linear-to-br from-muted to-muted-foreground/10" />
<div v-else class="absolute inset-0">
<BookCoverPlaceholder
:title="candidateSeed ?? null"
:author-line="candidateAuthorLine ?? null"
:is-audio="false"
:seed="candidateSeed ?? 'candidate'"
/>
</div>
<div
v-if="field.candidateDisplay"
class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { BookOpen } from 'lucide-vue-next'
import type { MetadataCandidate, MetadataProviderInfo } from '@bookorbit/types'
import { getProviderLabel, hideOnError, providerBadgeStyle, toDisplayCoverUrl } from '../../../lib/metadata-fetch'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
import BookCoverPlaceholder from '@/features/book/components/BookCoverPlaceholder.vue'
const props = defineProps<{
candidate: MetadataCandidate
@@ -16,6 +16,8 @@ const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT
const providerLabel = computed(() => getProviderLabel(props.candidate.provider, props.providers))
const displayCoverUrl = computed(() => toDisplayCoverUrl(props.candidate.coverUrl))
const candidateSeed = computed(() => props.candidate.title ?? props.candidate.provider)
const candidateAuthorLine = computed(() => props.candidate.authors?.join(', ') || null)
function handleSelect() {
emit('select', props.candidate)
@@ -36,9 +38,7 @@ function handleSelect() {
class="w-full h-full object-contain transition-transform duration-500 group-hover:scale-105"
@error="hideOnError"
/>
<span v-else class="absolute inset-0 flex items-center justify-center bg-linear-to-br from-surface-2 to-surface-4">
<BookOpen class="size-6 text-muted-foreground/25" />
</span>
<BookCoverPlaceholder v-else :title="candidateSeed" :author-line="candidateAuthorLine" :is-audio="false" :seed="candidateSeed" />
</span>
<!-- Info -->
@@ -25,6 +25,8 @@ import {
} from 'lucide-vue-next'
import type { AudiobookChapter, BookDetail, BookDetailFile } from '@bookorbit/types'
import { api } from '@/lib/api'
import BookCoverPlaceholder from '@/features/book/components/BookCoverPlaceholder.vue'
import { bookCoverPalette } from '@/features/book/lib/book-cover'
import { useAudioProgress } from './composables/useAudioProgress'
import { useAudioQueue } from './composables/useAudioQueue'
import { useAudioSettings } from './composables/useAudioSettings'
@@ -217,6 +219,9 @@ const displayTitle = computed(() => {
return detail.value.folderPath.split('/').pop() || 'Untitled'
})
const coverSeed = computed(() => detail.value?.title ?? detail.value?.folderPath.split('/').pop() ?? String(props.bookId))
const coverPalette = computed(() => bookCoverPalette(coverSeed.value))
// ── Media Session ─────────────────────────────────────────────────────────────
watch(displayTitle, (newTitle) => {
@@ -724,7 +729,7 @@ onMounted(async () => {
class="absolute inset-0 scale-110"
:style="{ backgroundImage: `url(/api/v1/books/${props.bookId}/cover)`, backgroundSize: 'cover', backgroundPosition: 'center' }"
/>
<div v-else class="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-indigo-950" />
<div v-else class="absolute inset-0" :style="{ background: coverPalette.gradient }" />
<div class="absolute inset-0 backdrop-blur-3xl bg-black/60" />
</div>
@@ -784,15 +789,19 @@ onMounted(async () => {
:style="
detail.coverSource
? { backgroundImage: `url(/api/v1/books/${props.bookId}/cover)`, backgroundSize: 'cover', backgroundPosition: 'center' }
: { background: 'rgba(255,255,255,0.2)' }
: { background: coverPalette.gradient }
"
/>
<!-- Cover -->
<div class="absolute inset-0 rounded-2xl overflow-hidden ring-1 ring-white/10 shadow-2xl">
<img v-if="detail.coverSource" :src="`/api/v1/books/${props.bookId}/cover`" class="w-full h-full object-cover" :alt="displayTitle" />
<div v-else class="w-full h-full flex items-center justify-center bg-white/5">
<BookOpen class="w-16 h-16 text-white/25" />
</div>
<BookCoverPlaceholder
v-else
:title="detail.title"
:author-line="detail.authors.map((a) => a.name).join(', ') || null"
:is-audio="true"
:seed="coverSeed"
/>
</div>
</div>
@@ -559,6 +559,7 @@ watch(
:cover-size="seriesBooksCoverSize"
:grid-gap="gridGap"
@action="handleBookAction"
@update:book="handleTableBookUpdate"
/>
<div v-if="effectiveViewMode === 'list' && books.length > 0" class="flex flex-col divide-y divide-border">
+1
View File
@@ -805,6 +805,7 @@ async function handleToggleCollapse() {
:new-book-ids="newBookIds"
@action="handleBookAction"
@select="handleSelect"
@update:book="handleTableBookUpdate"
/>
<!-- List view -->
+4
View File
@@ -153,12 +153,16 @@ export type BooksPage = {
export type BookRecommendation = {
id: number;
title: string | null;
hasCover: boolean;
authors: string[];
};
export type SeriesBookRecommendation = {
id: number;
title: string | null;
seriesIndex: number | null;
hasCover: boolean;
authors: string[];
};
export type CoverSearchResult = {
+217 -104
View File
@@ -239,6 +239,12 @@ importers:
"@fastify/static":
specifier: ^9.1.3
version: 9.1.3
"@ffmpeg-installer/ffmpeg":
specifier: ^1.1.0
version: 1.1.0
"@ffprobe-installer/ffprobe":
specifier: ^2.1.2
version: 2.1.2
"@nestjs/common":
specifier: ^11.1.19
version: 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -308,15 +314,9 @@ importers:
jose:
specifier: ^6.2.3
version: 6.2.3
mediainfo.js:
specifier: ^0.3.7
version: 0.3.7
ms:
specifier: ^2.1.3
version: 2.1.3
music-metadata:
specifier: ^11.12.3
version: 11.12.3
mysql2:
specifier: ^3.22.3
version: 3.22.3(@types/node@25.6.0)
@@ -1680,6 +1680,147 @@ packages:
integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==,
}
"@ffmpeg-installer/darwin-arm64@4.1.5":
resolution:
{
integrity: sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==,
}
cpu: [arm64]
os: [darwin]
"@ffmpeg-installer/darwin-x64@4.1.0":
resolution:
{
integrity: sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==,
}
cpu: [x64]
os: [darwin]
"@ffmpeg-installer/ffmpeg@1.1.0":
resolution:
{
integrity: sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==,
}
"@ffmpeg-installer/linux-arm64@4.1.4":
resolution:
{
integrity: sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==,
}
cpu: [arm64]
os: [linux]
"@ffmpeg-installer/linux-arm@4.1.3":
resolution:
{
integrity: sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==,
}
cpu: [arm]
os: [linux]
"@ffmpeg-installer/linux-ia32@4.1.0":
resolution:
{
integrity: sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==,
}
cpu: [ia32]
os: [linux]
"@ffmpeg-installer/linux-x64@4.1.0":
resolution:
{
integrity: sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==,
}
cpu: [x64]
os: [linux]
"@ffmpeg-installer/win32-ia32@4.1.0":
resolution:
{
integrity: sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==,
}
cpu: [ia32]
os: [win32]
"@ffmpeg-installer/win32-x64@4.1.0":
resolution:
{
integrity: sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==,
}
cpu: [x64]
os: [win32]
"@ffprobe-installer/darwin-arm64@5.0.1":
resolution:
{
integrity: sha512-vwNCNjokH8hfkbl6m95zICHwkSzhEvDC3GVBcUp5HX8+4wsX10SP3B+bGur7XUzTIZ4cQpgJmEIAx6TUwRepMg==,
}
cpu: [arm64]
os: [darwin]
"@ffprobe-installer/darwin-x64@5.1.0":
resolution:
{
integrity: sha512-J+YGscZMpQclFg31O4cfVRGmDpkVsQ2fZujoUdMAAYcP0NtqpC49Hs3SWJpBdsGB4VeqOt5TTm1vSZQzs1NkhA==,
}
cpu: [x64]
os: [darwin]
"@ffprobe-installer/ffprobe@2.1.2":
resolution:
{
integrity: sha512-ZNvwk4f2magF42Zji2Ese16SMj9BS7Fui4kRjg6gTYTxY3gWZNpg85n4MIfQyI9nimHg4x/gT6FVkp/bBDuBwg==,
}
engines: { node: ">=14.21.2" }
"@ffprobe-installer/linux-arm64@5.2.0":
resolution:
{
integrity: sha512-X1VvWtlLs6ScP73biVLuHD5ohKJKsMTa0vafCESOen4mOoNeLAYbxOVxDWAdFz9cpZgRiloFj5QD6nDj8E28yQ==,
}
cpu: [arm64]
os: [linux]
"@ffprobe-installer/linux-arm@5.2.0":
resolution:
{
integrity: sha512-PF5HqEhCY7WTWHtLDYbA/+rLS+rhslWvyBlAG1Fk8VzVlnRdl93o6hy7DE2kJgxWQbFaR3ZktPQGEzfkrmQHvQ==,
}
cpu: [arm]
os: [linux]
"@ffprobe-installer/linux-ia32@5.2.0":
resolution:
{
integrity: sha512-TFVK5sasXyXhbIG7LtPRDmtkrkOsInwKcL43iEvEw+D9vCS2rc//mn9/0Q+BR0UoJEiMK4+ApYr/3LLVUBPOCQ==,
}
cpu: [ia32]
os: [linux]
"@ffprobe-installer/linux-x64@5.2.0":
resolution:
{
integrity: sha512-D3UeqTLYPNs7pBWPLUYGehPdRVqU8eACox4OZy3pZUZatxye2YKlvBwEfaLdL1v2Z4FOAlLUhms0kY8m8kqSRA==,
}
cpu: [x64]
os: [linux]
"@ffprobe-installer/win32-ia32@5.1.0":
resolution:
{
integrity: sha512-5O3vOoNRxmut0/Nu9vSazTdSHasrr+zPT2B3Hm7kjmO3QVFcIfVImS6ReQnZeSy8JPJOqXts5kX5x/3KOX54XQ==,
}
cpu: [ia32]
os: [win32]
"@ffprobe-installer/win32-x64@5.1.0":
resolution:
{
integrity: sha512-jMGYeAgkrdn4e2vvYt/qakgHRE3CPju4bn5TmdPfoAm1BlX1mY9cyMd8gf5vSzI8gH8Zq5WQAyAkmekX/8TSTg==,
}
cpu: [x64]
os: [win32]
"@floating-ui/core@1.7.5":
resolution:
{
@@ -5664,13 +5805,6 @@ packages:
}
engines: { node: ">=12" }
cliui@9.0.1:
resolution:
{
integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==,
}
engines: { node: ">=20" }
clone@1.0.4:
resolution:
{
@@ -5836,13 +5970,6 @@ packages:
}
engines: { node: ">=18" }
content-type@1.0.5:
resolution:
{
integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==,
}
engines: { node: ">= 0.6" }
conventional-changelog-angular@7.0.0:
resolution:
{
@@ -8577,21 +8704,6 @@ packages:
integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==,
}
media-typer@1.1.0:
resolution:
{
integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==,
}
engines: { node: ">= 0.8" }
mediainfo.js@0.3.7:
resolution:
{
integrity: sha512-mgsmb2GrCTAguVcTohW7KrF+QXNaihrbljj75pLWDQcTTQyqfGoZG4Dy++AZ+FDVCT14LHvVGC9SXxcF5VePag==,
}
engines: { node: ">=18.0.0" }
hasBin: true
memfs@3.5.3:
resolution:
{
@@ -8791,13 +8903,6 @@ packages:
integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==,
}
music-metadata@11.12.3:
resolution:
{
integrity: sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ==,
}
engines: { node: ">=18" }
mute-stream@2.0.0:
resolution:
{
@@ -11610,12 +11715,6 @@ packages:
}
engines: { node: ">=8" }
win-guid@0.2.1:
resolution:
{
integrity: sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==,
}
word-wrap@1.2.5:
resolution:
{
@@ -11754,13 +11853,6 @@ packages:
}
engines: { node: ">=12" }
yargs-parser@22.0.0:
resolution:
{
integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==,
}
engines: { node: ^20.19.0 || ^22.12.0 || >=23 }
yargs@16.2.0:
resolution:
{
@@ -11775,13 +11867,6 @@ packages:
}
engines: { node: ">=12" }
yargs@18.0.0:
resolution:
{
integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==,
}
engines: { node: ^20.19.0 || ^22.12.0 || >=23 }
yauzl@3.3.0:
resolution:
{
@@ -12566,6 +12651,76 @@ snapshots:
fastq: 1.20.1
glob: 13.0.6
"@ffmpeg-installer/darwin-arm64@4.1.5":
optional: true
"@ffmpeg-installer/darwin-x64@4.1.0":
optional: true
"@ffmpeg-installer/ffmpeg@1.1.0":
optionalDependencies:
"@ffmpeg-installer/darwin-arm64": 4.1.5
"@ffmpeg-installer/darwin-x64": 4.1.0
"@ffmpeg-installer/linux-arm": 4.1.3
"@ffmpeg-installer/linux-arm64": 4.1.4
"@ffmpeg-installer/linux-ia32": 4.1.0
"@ffmpeg-installer/linux-x64": 4.1.0
"@ffmpeg-installer/win32-ia32": 4.1.0
"@ffmpeg-installer/win32-x64": 4.1.0
"@ffmpeg-installer/linux-arm64@4.1.4":
optional: true
"@ffmpeg-installer/linux-arm@4.1.3":
optional: true
"@ffmpeg-installer/linux-ia32@4.1.0":
optional: true
"@ffmpeg-installer/linux-x64@4.1.0":
optional: true
"@ffmpeg-installer/win32-ia32@4.1.0":
optional: true
"@ffmpeg-installer/win32-x64@4.1.0":
optional: true
"@ffprobe-installer/darwin-arm64@5.0.1":
optional: true
"@ffprobe-installer/darwin-x64@5.1.0":
optional: true
"@ffprobe-installer/ffprobe@2.1.2":
optionalDependencies:
"@ffprobe-installer/darwin-arm64": 5.0.1
"@ffprobe-installer/darwin-x64": 5.1.0
"@ffprobe-installer/linux-arm": 5.2.0
"@ffprobe-installer/linux-arm64": 5.2.0
"@ffprobe-installer/linux-ia32": 5.2.0
"@ffprobe-installer/linux-x64": 5.2.0
"@ffprobe-installer/win32-ia32": 5.1.0
"@ffprobe-installer/win32-x64": 5.1.0
"@ffprobe-installer/linux-arm64@5.2.0":
optional: true
"@ffprobe-installer/linux-arm@5.2.0":
optional: true
"@ffprobe-installer/linux-ia32@5.2.0":
optional: true
"@ffprobe-installer/linux-x64@5.2.0":
optional: true
"@ffprobe-installer/win32-ia32@5.1.0":
optional: true
"@ffprobe-installer/win32-x64@5.1.0":
optional: true
"@floating-ui/core@1.7.5":
dependencies:
"@floating-ui/utils": 0.2.11
@@ -15016,12 +15171,6 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
cliui@9.0.1:
dependencies:
string-width: 7.2.0
strip-ansi: 7.2.0
wrap-ansi: 9.0.2
clone@1.0.4: {}
clsx@2.1.1: {}
@@ -15100,8 +15249,6 @@ snapshots:
content-disposition@1.1.0: {}
content-type@1.0.5: {}
conventional-changelog-angular@7.0.0:
dependencies:
compare-func: 2.0.0
@@ -16646,12 +16793,6 @@ snapshots:
mdn-data@2.27.1: {}
media-typer@1.1.0: {}
mediainfo.js@0.3.7:
dependencies:
yargs: 18.0.0
memfs@3.5.3:
dependencies:
fs-monkey: 1.1.0
@@ -16730,21 +16871,6 @@ snapshots:
muggle-string@0.4.1: {}
music-metadata@11.12.3:
dependencies:
"@borewit/text-codec": 0.2.2
"@tokenizer/token": 0.3.0
content-type: 1.0.5
debug: 4.4.3
file-type: 21.3.4
media-typer: 1.1.0
strtok3: 10.3.5
token-types: 6.1.2
uint8array-extras: 1.5.0
win-guid: 0.2.1
transitivePeerDependencies:
- supports-color
mute-stream@2.0.0: {}
mysql2@3.22.3(@types/node@25.6.0):
@@ -18441,8 +18567,6 @@ snapshots:
dependencies:
string-width: 4.2.3
win-guid@0.2.1: {}
word-wrap@1.2.5: {}
wordwrap@1.0.0: {}
@@ -18499,8 +18623,6 @@ snapshots:
yargs-parser@21.1.1: {}
yargs-parser@22.0.0: {}
yargs@16.2.0:
dependencies:
cliui: 7.0.4
@@ -18521,15 +18643,6 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
yargs@18.0.0:
dependencies:
cliui: 9.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
string-width: 7.2.0
y18n: 5.0.8
yargs-parser: 22.0.0
yauzl@3.3.0:
dependencies:
buffer-crc32: 0.2.13
+2 -2
View File
@@ -43,6 +43,8 @@
"@fastify/helmet": "^13.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"@ffprobe-installer/ffprobe": "^2.1.2",
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
@@ -66,9 +68,7 @@
"fastest-levenshtein": "^1.0.16",
"fastify": "^5.8.5",
"jose": "^6.2.3",
"mediainfo.js": "^0.3.7",
"ms": "^2.1.3",
"music-metadata": "^11.12.3",
"mysql2": "^3.22.3",
"nestjs-pino": "^4.6.1",
"node-unrar-js": "^2.0.2",
@@ -216,9 +216,45 @@ describe('BookRepository', () => {
expect(db.select).not.toHaveBeenCalled();
});
it('maps hasCover from coverSource and aggregates authors per book in recommendation rows', async () => {
const bookRows = [
{ id: 10, title: 'Dune', coverSource: 'extracted' },
{ id: 11, title: 'Foundation', coverSource: null },
];
const authorRows = [
{ bookId: 10, name: 'Frank Herbert' },
{ bookId: 11, name: 'Isaac Asimov' },
{ bookId: 11, name: 'Robert Heinlein' },
];
const db = {
select: vi.fn().mockReturnValueOnce(makeSelectChain('where', bookRows)).mockReturnValueOnce(makeSelectChain('where', authorRows)),
};
const repo = new BookRepository(db as never);
const result = await repo.findRecommendationTitlesByBookIds([10, 11]);
expect(result).toEqual([
{ id: 10, title: 'Dune', hasCover: true, authors: ['Frank Herbert'] },
{ id: 11, title: 'Foundation', hasCover: false, authors: ['Isaac Asimov', 'Robert Heinlein'] },
]);
});
it('returns hasCover false when coverSource is null in recommendation rows', async () => {
const bookRows = [{ id: 5, title: 'No Cover', coverSource: null }];
const db = {
select: vi.fn().mockReturnValueOnce(makeSelectChain('where', bookRows)).mockReturnValueOnce(makeSelectChain('where', [])),
};
const repo = new BookRepository(db as never);
const result = await repo.findRecommendationTitlesByBookIds([5]);
expect(result).toEqual([{ id: 5, title: 'No Cover', hasCover: false, authors: [] }]);
});
it('maps id-list helper rows and primary-file lookups', async () => {
const libraryRows = [{ id: 1, libraryId: 7 }];
const recommendationRows = [{ id: 1, title: 'Dune' }];
const recommendationBookRows = [{ id: 1, title: 'Dune', coverSource: 'extracted' }];
const recommendationAuthorRows = [{ bookId: 1, name: 'Frank Herbert' }];
const allIdsRows = [{ id: 3 }, { id: 4 }];
const primaryFileRows = [{ absolutePath: '/books/a.epub', format: 'epub' }];
const missingPrimaryRows: unknown[] = [];
@@ -231,7 +267,8 @@ describe('BookRepository', () => {
select: vi
.fn()
.mockReturnValueOnce(makeSelectChain('where', libraryRows))
.mockReturnValueOnce(makeSelectChain('where', recommendationRows))
.mockReturnValueOnce(makeSelectChain('where', recommendationBookRows))
.mockReturnValueOnce(makeSelectChain('where', recommendationAuthorRows))
.mockReturnValueOnce(makeSelectChain('orderBy', primaryFilesByIds))
.mockReturnValueOnce(makeSelectChain('orderBy', allFilesByIds))
.mockReturnValueOnce(allIdsChain)
@@ -241,7 +278,9 @@ describe('BookRepository', () => {
const repo = new BookRepository(db as never);
await expect(repo.findLibraryIdsByBookIds([1])).resolves.toEqual(libraryRows);
await expect(repo.findRecommendationTitlesByBookIds([1])).resolves.toEqual(recommendationRows);
await expect(repo.findRecommendationTitlesByBookIds([1])).resolves.toEqual([
{ id: 1, title: 'Dune', hasCover: true, authors: ['Frank Herbert'] },
]);
await expect(repo.findPrimaryFilesByBookIds([1])).resolves.toEqual(primaryFilesByIds);
await expect(repo.findAllFilesByBookIds([1])).resolves.toEqual(allFilesByIds);
await expect(repo.findAllIds()).resolves.toEqual([3, 4]);
+28 -6
View File
@@ -643,13 +643,35 @@ export class BookRepository {
return this.db.select({ id: books.id, libraryId: books.libraryId }).from(books).where(inArray(books.id, bookIds));
}
async findRecommendationTitlesByBookIds(bookIds: number[]): Promise<{ id: number; title: string | null }[]> {
async findRecommendationTitlesByBookIds(bookIds: number[]): Promise<{ id: number; title: string | null; hasCover: boolean; authors: string[] }[]> {
if (bookIds.length === 0) return [];
return this.db
.select({ id: books.id, title: bookMetadata.title })
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(inArray(books.id, bookIds));
const [rows, authorRows] = await Promise.all([
this.db
.select({ id: books.id, title: bookMetadata.title, coverSource: bookMetadata.coverSource })
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(inArray(books.id, bookIds)),
this.db
.select({ bookId: bookAuthors.bookId, name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(inArray(bookAuthors.bookId, bookIds)),
]);
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
const names = authorsByBook.get(row.bookId) ?? [];
names.push(row.name);
authorsByBook.set(row.bookId, names);
}
return rows.map((r) => ({
id: r.id,
title: r.title,
hasCover: r.coverSource !== null,
authors: authorsByBook.get(r.id) ?? [],
}));
}
async findPatternMetadataByBookIds(bookIds: number[]): Promise<PatternMetadataRow[]> {
@@ -1,168 +1,185 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import type { IAudioMetadata } from 'music-metadata';
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
vi.mock('music-metadata', () => ({
parseFile: vi.fn(),
}));
vi.mock('fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs/promises')>();
return {
...actual,
open: vi.fn(),
};
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return { ...actual, execFile: vi.fn(), spawn: vi.fn() };
});
vi.mock('mediainfo.js', () => ({
default: vi.fn(),
}));
vi.mock('@ffprobe-installer/ffprobe', () => ({ default: { path: '/mock/ffprobe' } }));
vi.mock('@ffmpeg-installer/ffmpeg', () => ({ default: { path: '/mock/ffmpeg' } }));
import * as mm from 'music-metadata';
import { open } from 'fs/promises';
import mediaInfoFactory from 'mediainfo.js';
import { execFile as execFileCallback, spawn } from 'child_process';
import { extractAudioMetadata, parseAudioDuration } from './audio.extractor';
import { EventEmitter } from 'events';
const mockParseFile = mm.parseFile as ReturnType<typeof vi.fn>;
const mockOpen = open as unknown as ReturnType<typeof vi.fn>;
const mockMediaInfoFactory = mediaInfoFactory as unknown as ReturnType<typeof vi.fn>;
const mockExecFile = execFileCallback as unknown as Mock;
const mockSpawn = spawn as unknown as Mock;
function makeMetadata(overrides: Partial<IAudioMetadata> = {}): IAudioMetadata {
const base = {
common: {
track: { no: null, of: null },
disk: { no: null, of: null },
movementIndex: {},
},
format: {
tagTypes: [],
lossless: false,
},
native: {},
quality: { warnings: [] },
interface FfprobeOutput {
format?: {
duration?: string;
tags?: Record<string, string>;
};
return {
...base,
...overrides,
common: { ...base.common, ...(overrides.common ?? {}) },
format: { ...base.format, ...(overrides.format ?? {}) },
} as unknown as IAudioMetadata;
streams?: Array<{ codec_type: string; codec_name?: string; tags?: Record<string, string> }>;
chapters?: Array<{ start_time: string; tags?: { title?: string } }>;
}
function resetAudioMocks() {
vi.resetAllMocks();
mockOpen.mockRejectedValue(new Error('no mediainfo file handle'));
mockMediaInfoFactory.mockResolvedValue({
analyzeData: vi.fn().mockResolvedValue({ media: { track: [] } }),
close: vi.fn(),
function makeProbeOutput(overrides: FfprobeOutput = {}): string {
const base: FfprobeOutput = {
format: { duration: '3600', tags: {} },
streams: [{ codec_type: 'audio', codec_name: 'aac' }],
chapters: [],
};
return JSON.stringify({
format: { ...base.format, ...overrides.format, tags: { ...(base.format?.tags ?? {}), ...(overrides.format?.tags ?? {}) } },
streams: overrides.streams ?? base.streams,
chapters: overrides.chapters ?? base.chapters,
});
}
type ExecFileCallback = (err: Error | null, result: { stdout: string; stderr: string } | string) => void;
function makeExecFileSuccess(stdout: string) {
mockExecFile.mockImplementation((_bin: string, _args: string[], callback: ExecFileCallback) => {
callback(null, { stdout, stderr: '' });
});
}
function makeExecFileError(message: string) {
mockExecFile.mockImplementation((_bin: string, _args: string[], callback: ExecFileCallback) => {
callback(new Error(message), '');
});
}
function makeSpawnProcess(coverBytes: Buffer | null): EventEmitter & { stdout: EventEmitter } {
const proc = new EventEmitter() as EventEmitter & { stdout: EventEmitter };
proc.stdout = new EventEmitter();
setImmediate(() => {
if (coverBytes) proc.stdout.emit('data', coverBytes);
proc.emit('close', coverBytes ? 0 : 1);
});
return proc;
}
function makeSpawnError() {
const proc = new EventEmitter() as EventEmitter & { stdout: EventEmitter };
proc.stdout = new EventEmitter();
setImmediate(() => proc.emit('error', new Error('spawn failed')));
return proc;
}
function resetMocks() {
vi.resetAllMocks();
mockSpawn.mockReturnValue(makeSpawnProcess(null));
}
// ── AUTHOR / NARRATOR SPLIT ───────────────────────────────────────────────────
describe('extractAudioMetadata — author/narrator split', () => {
beforeEach(() => resetAudioMocks());
beforeEach(() => resetMocks());
it('uses albumartist as author and artists as narrators', async () => {
mockParseFile.mockResolvedValue(
makeMetadata({
common: {
albumartist: 'J.R.R. Tolkien',
artists: ['Andy Serkis'],
album: 'The Lord of the Rings',
} as unknown as IAudioMetadata['common'],
}),
it('uses albumartist as author and artist as narrator', async () => {
makeExecFileSuccess(
makeProbeOutput({ format: { tags: { album: 'The Lord of the Rings', albumartist: 'J.R.R. Tolkien', artist: 'Andy Serkis' } } }),
);
const result = await extractAudioMetadata('/path/lotr.m4b');
expect(result.authors).toEqual([{ name: 'J.R.R. Tolkien', sortName: null }]);
expect(result.narrators).toEqual(['Andy Serkis']);
});
it('uses artist as author and leaves narrators empty when albumartist is absent', async () => {
mockParseFile.mockResolvedValue(
makeMetadata({
common: {
artist: 'Douglas Adams',
album: "The Hitchhiker's Guide",
} as unknown as IAudioMetadata['common'],
}),
);
makeExecFileSuccess(makeProbeOutput({ format: { tags: { album: "The Hitchhiker's Guide", artist: 'Douglas Adams' } } }));
const result = await extractAudioMetadata('/path/hhg.mp3');
expect(result.authors).toEqual([{ name: 'Douglas Adams', sortName: null }]);
expect(result.narrators).toEqual([]);
});
it('splits multiple albumartists by semicolons and slashes', async () => {
mockParseFile.mockResolvedValue(
makeMetadata({
common: {
albumartist: 'Terry Pratchett; Neil Gaiman',
album: 'Good Omens',
} as unknown as IAudioMetadata['common'],
}),
);
it('splits albumartist by semicolons', async () => {
makeExecFileSuccess(makeProbeOutput({ format: { tags: { album: 'Good Omens', albumartist: 'Terry Pratchett; Neil Gaiman' } } }));
const result = await extractAudioMetadata('/path/good-omens.m4b');
expect(result.authors).toHaveLength(2);
expect(result.authors[0].name).toBe('Terry Pratchett');
expect(result.authors[1].name).toBe('Neil Gaiman');
});
it('splits narrators by slash', async () => {
mockParseFile.mockResolvedValue(
makeMetadata({
common: {
albumartist: 'Frank Herbert',
artists: ['Scott Brick / Oliver Wyman'],
album: 'Dune',
} as unknown as IAudioMetadata['common'],
}),
);
it('splits albumartist by slashes', async () => {
makeExecFileSuccess(makeProbeOutput({ format: { tags: { albumartist: 'Auth A / Auth B', artist: 'Narrator' } } }));
const result = await extractAudioMetadata('/path/book.m4b');
expect(result.authors).toHaveLength(2);
expect(result.authors[0].name).toBe('Auth A');
expect(result.authors[1].name).toBe('Auth B');
});
it('splits narrators by slash when albumartist is set', async () => {
makeExecFileSuccess(makeProbeOutput({ format: { tags: { album: 'Dune', albumartist: 'Frank Herbert', artist: 'Scott Brick / Oliver Wyman' } } }));
const result = await extractAudioMetadata('/path/dune.m4b');
expect(result.narrators).toHaveLength(2);
expect(result.narrators[0]).toBe('Scott Brick');
expect(result.narrators[1]).toBe('Oliver Wyman');
});
it('returns empty authors and narrators when neither albumartist nor artist is set', async () => {
makeExecFileSuccess(makeProbeOutput({ format: { tags: { album: 'Unknown Book' } } }));
const result = await extractAudioMetadata('/path/unknown.m4b');
expect(result.authors).toEqual([]);
expect(result.narrators).toEqual([]);
});
it('handles album_artist tag as fallback for albumartist', async () => {
makeExecFileSuccess(makeProbeOutput({ format: { tags: { album: 'Foundation', album_artist: 'Isaac Asimov' } } }));
const result = await extractAudioMetadata('/path/foundation.m4b');
expect(result.authors).toEqual([{ name: 'Isaac Asimov', sortName: null }]);
});
it('all sortNames are null', async () => {
makeExecFileSuccess(makeProbeOutput({ format: { tags: { albumartist: 'Author One' } } }));
const result = await extractAudioMetadata('/path/book.m4b');
result.authors.forEach((a) => expect(a.sortName).toBeNull());
});
});
// ── TITLE RESOLUTION ─────────────────────────────────────────────────────────
describe('extractAudioMetadata — title', () => {
beforeEach(() => resetAudioMocks());
beforeEach(() => resetMocks());
it('prefers album tag over track title', async () => {
mockParseFile.mockResolvedValue(
makeMetadata({
common: {
album: 'Foundation',
title: 'Part 1',
} as unknown as IAudioMetadata['common'],
}),
);
it('prefers album tag over title tag', async () => {
makeExecFileSuccess(makeProbeOutput({ format: { tags: { album: 'Foundation', title: 'Part 1' } } }));
const result = await extractAudioMetadata('/path/file.mp3');
expect(result.title).toBe('Foundation');
});
it('falls back to track title when album is absent', async () => {
mockParseFile.mockResolvedValue(
makeMetadata({
common: {
title: 'Standalone Track',
} as unknown as IAudioMetadata['common'],
}),
);
it('falls back to title tag when album is absent', async () => {
makeExecFileSuccess(makeProbeOutput({ format: { tags: { title: 'Standalone Track' } } }));
const result = await extractAudioMetadata('/path/track.mp3');
expect(result.title).toBe('Standalone Track');
});
it('returns null title when neither album nor title is present', async () => {
mockParseFile.mockResolvedValue(makeMetadata());
makeExecFileSuccess(makeProbeOutput({ format: { tags: {} } }));
const result = await extractAudioMetadata('/path/noname.flac');
expect(result.title).toBeNull();
});
});
@@ -170,22 +187,21 @@ describe('extractAudioMetadata — title', () => {
// ── DURATION ─────────────────────────────────────────────────────────────────
describe('extractAudioMetadata — duration', () => {
beforeEach(() => resetAudioMocks());
beforeEach(() => resetMocks());
it('rounds duration to integer seconds', async () => {
mockParseFile.mockResolvedValue(
makeMetadata({
format: { duration: 3661.7 } as unknown as IAudioMetadata['format'],
}),
);
makeExecFileSuccess(makeProbeOutput({ format: { duration: '3661.7', tags: {} } }));
const result = await extractAudioMetadata('/path/book.m4b');
expect(result.durationSeconds).toBe(3662);
});
it('returns null duration when not present', async () => {
mockParseFile.mockResolvedValue(makeMetadata());
const result = await extractAudioMetadata('/path/book.m4b');
it('returns null duration when format.duration is absent', async () => {
makeExecFileSuccess(JSON.stringify({ format: { tags: {} }, streams: [], chapters: [] }));
const result = await extractAudioMetadata('/path/no-duration.m4b');
expect(result.durationSeconds).toBeNull();
});
});
@@ -193,76 +209,44 @@ describe('extractAudioMetadata — duration', () => {
// ── CHAPTERS ─────────────────────────────────────────────────────────────────
describe('extractAudioMetadata — chapters', () => {
beforeEach(() => resetAudioMocks());
beforeEach(() => resetMocks());
it('prefers chapter markers extracted by mediainfo and sorts them by timestamp', async () => {
const close = vi.fn();
const read = vi.fn().mockResolvedValue(undefined);
const stat = vi.fn().mockResolvedValue({ size: 4096 });
mockOpen.mockResolvedValue({ stat, read, close });
mockMediaInfoFactory.mockResolvedValue({
analyzeData: vi.fn().mockResolvedValue({
media: {
track: [
{
'@type': 'Menu',
extra: {
_00_00_10_500: 'Chapter 2',
_00_00_00_000: 'Introduction',
},
},
],
},
}),
close: vi.fn(),
});
mockParseFile.mockResolvedValue(makeMetadata());
const result = await extractAudioMetadata('/path/mediainfo.m4b');
expect(result.chapters).toEqual([
{ title: 'Introduction', startMs: 0 },
{ title: 'Chapter 2', startMs: 10500 },
]);
expect(close).toHaveBeenCalledTimes(1);
});
it('converts chapter sampleOffset to startMs using sampleRate', async () => {
mockParseFile.mockResolvedValue(
makeMetadata({
format: {
sampleRate: 44100,
chapters: [
{ title: 'Introduction', sampleOffset: 0, sampleLength: 44100 },
{ title: 'Chapter 1', sampleOffset: 44100, sampleLength: 88200 },
{ title: 'Chapter 2', sampleOffset: 132300, sampleLength: 44100 },
],
} as unknown as IAudioMetadata['format'],
it('maps ffprobe chapters to startMs and title', async () => {
makeExecFileSuccess(
makeProbeOutput({
chapters: [
{ start_time: '0.000000', tags: { title: 'Introduction' } },
{ start_time: '945.530000', tags: { title: 'Chapter 2' } },
{ start_time: '2263.696667', tags: { title: 'Chapter 3' } },
],
}),
);
const result = await extractAudioMetadata('/path/book.m4b');
expect(result.chapters).toHaveLength(3);
expect(result.chapters[0]).toEqual({ title: 'Introduction', startMs: 0 });
expect(result.chapters[1]).toEqual({ title: 'Chapter 1', startMs: 1000 });
expect(result.chapters[2]).toEqual({ title: 'Chapter 2', startMs: 3000 });
expect(result.chapters[1]).toEqual({ title: 'Chapter 2', startMs: 945530 });
expect(result.chapters[2]).toEqual({ title: 'Chapter 3', startMs: 2263697 });
});
it('uses default sampleRate of 44100 when not specified', async () => {
mockParseFile.mockResolvedValue(
makeMetadata({
format: {
chapters: [{ title: 'Ch1', sampleOffset: 44100, sampleLength: 44100 }],
} as unknown as IAudioMetadata['format'],
it('handles chapters with no title tag', async () => {
makeExecFileSuccess(
makeProbeOutput({
chapters: [{ start_time: '100.000', tags: {} }],
}),
);
const result = await extractAudioMetadata('/path/book.mp3');
expect(result.chapters[0].startMs).toBe(1000);
const result = await extractAudioMetadata('/path/book.m4b');
expect(result.chapters[0]).toEqual({ title: '', startMs: 100000 });
});
it('returns empty chapters when none are present', async () => {
mockParseFile.mockResolvedValue(makeMetadata());
it('returns empty chapters array when none present', async () => {
makeExecFileSuccess(makeProbeOutput({ chapters: [] }));
const result = await extractAudioMetadata('/path/no-chapters.mp3');
expect(result.chapters).toEqual([]);
});
});
@@ -270,87 +254,193 @@ describe('extractAudioMetadata — chapters', () => {
// ── COVER ─────────────────────────────────────────────────────────────────────
describe('extractAudioMetadata — cover', () => {
beforeEach(() => resetAudioMocks());
beforeEach(() => resetMocks());
it('extracts cover bytes from embedded picture', async () => {
const imageData = Buffer.from('fake-image-data');
mockParseFile.mockResolvedValue(
makeMetadata({
common: {
picture: [{ format: 'image/jpeg', data: new Uint8Array(imageData) }],
} as unknown as IAudioMetadata['common'],
it('extracts cover bytes when a video stream is present', async () => {
const fakeJpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x01, 0x02]);
makeExecFileSuccess(
makeProbeOutput({
streams: [
{ codec_type: 'audio', codec_name: 'aac' },
{ codec_type: 'video', codec_name: 'mjpeg' },
],
}),
);
mockSpawn.mockReturnValue(makeSpawnProcess(fakeJpeg));
const result = await extractAudioMetadata('/path/with-cover.m4b');
expect(result.coverBytes).toBeInstanceOf(Buffer);
expect(result.coverBytes).toEqual(fakeJpeg);
});
it('returns null cover when no video stream present', async () => {
makeExecFileSuccess(
makeProbeOutput({
streams: [{ codec_type: 'audio', codec_name: 'aac' }],
}),
);
const result = await extractAudioMetadata('/path/book.m4b');
expect(result.coverBytes).toBeInstanceOf(Buffer);
expect(result.coverBytes?.toString()).toBe('fake-image-data');
const result = await extractAudioMetadata('/path/no-cover.mp3');
expect(result.coverBytes).toBeNull();
expect(mockSpawn).not.toHaveBeenCalled();
});
it('returns null cover when no embedded picture', async () => {
mockParseFile.mockResolvedValue(makeMetadata());
const result = await extractAudioMetadata('/path/no-cover.mp3');
it('returns null cover when ffmpeg exits with non-zero code', async () => {
makeExecFileSuccess(
makeProbeOutput({
streams: [{ codec_type: 'video', codec_name: 'mjpeg' }],
}),
);
mockSpawn.mockReturnValue(makeSpawnProcess(null));
const result = await extractAudioMetadata('/path/broken-cover.m4b');
expect(result.coverBytes).toBeNull();
});
it('returns null cover when ffmpeg spawn emits an error', async () => {
makeExecFileSuccess(
makeProbeOutput({
streams: [{ codec_type: 'video', codec_name: 'mjpeg' }],
}),
);
mockSpawn.mockReturnValue(makeSpawnError());
const result = await extractAudioMetadata('/path/spawn-error.m4b');
expect(result.coverBytes).toBeNull();
});
it('spawns ffmpeg with correct arguments', async () => {
const fakeJpeg = Buffer.from([0xff, 0xd8]);
makeExecFileSuccess(
makeProbeOutput({
streams: [{ codec_type: 'video', codec_name: 'mjpeg' }],
}),
);
mockSpawn.mockReturnValue(makeSpawnProcess(fakeJpeg));
await extractAudioMetadata('/books/test.m4b');
expect(mockSpawn).toHaveBeenCalledWith(
'/mock/ffmpeg',
['-y', '-i', '/books/test.m4b', '-map', '0:v', '-frames:v', '1', '-f', 'image2pipe', '-vcodec', 'mjpeg', 'pipe:1'],
expect.objectContaining({ stdio: ['ignore', 'pipe', 'ignore'] }),
);
});
});
// ── OTHER FIELDS ─────────────────────────────────────────────────────────────
describe('extractAudioMetadata — misc fields', () => {
beforeEach(() => resetAudioMocks());
beforeEach(() => resetMocks());
it('maps publisher, publishedYear, description, language', async () => {
mockParseFile.mockResolvedValue(
makeMetadata({
common: {
label: ['Macmillan Audio'],
year: 2006,
comment: ['An epic sci-fi audiobook'],
language: 'eng',
} as unknown as IAudioMetadata['common'],
it('maps publisher, publishedYear from date tag, description, and language from tags', async () => {
makeExecFileSuccess(
makeProbeOutput({
format: { duration: '100', tags: { publisher: 'Macmillan Audio', date: '2006-04-15', comment: 'An epic sci-fi audiobook', language: 'eng' } },
}),
);
const result = await extractAudioMetadata('/path/book.m4b');
expect(result.publisher).toBe('Macmillan Audio');
expect(result.publishedYear).toBe(2006);
expect(result.description).toBe('An epic sci-fi audiobook');
expect(result.language).toBe('eng');
});
it('reads description from object comments and returns null when comment entries are blank', async () => {
mockParseFile.mockResolvedValueOnce(
makeMetadata({
common: {
comment: [{ text: 'Object-based comment' }],
} as unknown as IAudioMetadata['common'],
}),
);
mockParseFile.mockResolvedValueOnce(
makeMetadata({
common: {
comment: [' ', { text: ' ' }],
} as unknown as IAudioMetadata['common'],
it('parses year from a plain year string', async () => {
makeExecFileSuccess(makeProbeOutput({ format: { tags: { year: '1984' } } }));
const result = await extractAudioMetadata('/path/book.m4b');
expect(result.publishedYear).toBe(1984);
});
it('returns null publishedYear when date tag is absent', async () => {
makeExecFileSuccess(makeProbeOutput({ format: { tags: {} } }));
const result = await extractAudioMetadata('/path/no-year.mp3');
expect(result.publishedYear).toBeNull();
});
it('returns null publisher when tag is absent', async () => {
makeExecFileSuccess(makeProbeOutput({ format: { tags: {} } }));
const result = await extractAudioMetadata('/path/book.mp3');
expect(result.publisher).toBeNull();
});
it('returns null description when comment tag is absent', async () => {
makeExecFileSuccess(makeProbeOutput({ format: { tags: {} } }));
const result = await extractAudioMetadata('/path/no-desc.mp3');
expect(result.description).toBeNull();
});
it('resolves language from audio stream tags when format tag is absent', async () => {
makeExecFileSuccess(
makeProbeOutput({
streams: [{ codec_type: 'audio', codec_name: 'aac', tags: { language: 'fra' } }],
}),
);
const withObjectComment = await extractAudioMetadata('/path/object-comment.m4b');
const withBlankComments = await extractAudioMetadata('/path/blank-comment.m4b');
const result = await extractAudioMetadata('/path/french.m4b');
expect(withObjectComment.description).toBe('Object-based comment');
expect(withBlankComments.description).toBeNull();
expect(result.language).toBe('fra');
});
it('prefers format language tag over stream language', async () => {
makeExecFileSuccess(
makeProbeOutput({
format: { duration: '100', tags: { language: 'eng' } },
streams: [{ codec_type: 'audio', codec_name: 'aac', tags: { language: 'fra' } }],
}),
);
const result = await extractAudioMetadata('/path/book.m4b');
expect(result.language).toBe('eng');
});
it('returns null language when neither format nor stream has a language tag', async () => {
makeExecFileSuccess(
makeProbeOutput({
streams: [{ codec_type: 'audio', codec_name: 'aac' }],
}),
);
const result = await extractAudioMetadata('/path/no-lang.mp3');
expect(result.language).toBeNull();
});
it('handles uppercase tag keys by normalizing to lowercase', async () => {
makeExecFileSuccess(makeProbeOutput({ format: { tags: { ALBUM: 'Normalized Title', ARTIST: 'Normalized Author' } } }));
const result = await extractAudioMetadata('/path/uppercase-tags.mp3');
expect(result.title).toBe('Normalized Title');
expect(result.authors).toEqual([{ name: 'Normalized Author', sortName: null }]);
});
});
// ── FAILURE TOLERANCE ────────────────────────────────────────────────────────
describe('extractAudioMetadata — failure tolerance', () => {
beforeEach(() => resetAudioMocks());
beforeEach(() => resetMocks());
it('returns safe empty result when parseFile throws', async () => {
mockParseFile.mockRejectedValue(new Error('Cannot read file'));
it('returns all-null safe result when ffprobe exits with an error', async () => {
makeExecFileError('ffprobe: command not found');
const result = await extractAudioMetadata('/path/corrupted.mp3');
expect(result.title).toBeNull();
expect(result.authors).toEqual([]);
expect(result.narrators).toEqual([]);
@@ -359,40 +449,75 @@ describe('extractAudioMetadata — failure tolerance', () => {
expect(result.coverBytes).toBeNull();
});
it('returns safe empty result for a non-audio file', async () => {
mockParseFile.mockRejectedValue(new Error('Not an audio file'));
it('returns all-null safe result when ffprobe outputs invalid JSON', async () => {
makeExecFileSuccess('not valid json at all {{}}');
const result = await extractAudioMetadata('/path/bad-output.mp3');
const result = await extractAudioMetadata('/path/notaudio.txt');
expect(result.title).toBeNull();
expect(result.durationSeconds).toBeNull();
});
it('returns all-null safe result for a non-audio file', async () => {
makeExecFileError('Invalid data found when processing input');
const result = await extractAudioMetadata('/path/notaudio.txt');
expect(result.title).toBeNull();
expect(result.durationSeconds).toBeNull();
});
it('invokes ffprobe with correct arguments', async () => {
makeExecFileSuccess(makeProbeOutput());
await extractAudioMetadata('/books/my-audiobook.m4b');
expect(mockExecFile).toHaveBeenCalledWith(
'/mock/ffprobe',
['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_chapters', '-show_streams', '/books/my-audiobook.m4b'],
expect.any(Function),
);
});
});
// ── parseAudioDuration ────────────────────────────────────────────────────────
describe('parseAudioDuration', () => {
beforeEach(() => resetAudioMocks());
beforeEach(() => resetMocks());
it('returns rounded duration in seconds', async () => {
mockParseFile.mockResolvedValue(
makeMetadata({
format: { duration: 7200.4 } as unknown as IAudioMetadata['format'],
}),
);
makeExecFileSuccess(JSON.stringify({ format: { duration: '7200.4' } }));
const result = await parseAudioDuration('/path/book.m4b');
expect(result).toBe(7200);
});
it('returns null when duration is undefined', async () => {
mockParseFile.mockResolvedValue(makeMetadata());
const result = await parseAudioDuration('/path/book.mp3');
it('returns null when format.duration is missing', async () => {
makeExecFileSuccess(JSON.stringify({ format: {} }));
const result = await parseAudioDuration('/path/no-duration.mp3');
expect(result).toBeNull();
});
it('returns null when parseFile throws', async () => {
mockParseFile.mockRejectedValue(new Error('read error'));
it('returns null when ffprobe throws', async () => {
makeExecFileError('read error');
const result = await parseAudioDuration('/path/bad.mp3');
expect(result).toBeNull();
});
it('invokes ffprobe with format-only arguments (no chapters or streams)', async () => {
makeExecFileSuccess(JSON.stringify({ format: { duration: '100' } }));
await parseAudioDuration('/books/test.mp3');
expect(mockExecFile).toHaveBeenCalledWith(
'/mock/ffprobe',
['-v', 'quiet', '-print_format', 'json', '-show_format', '/books/test.mp3'],
expect.any(Function),
);
});
});
@@ -1,10 +1,11 @@
import { open } from 'fs/promises';
import mediaInfoFactory from 'mediainfo.js';
import * as mm from 'music-metadata';
import { execFile as execFileCallback, spawn } from 'child_process';
import { promisify } from 'util';
import ffprobeInstaller from '@ffprobe-installer/ffprobe';
import ffmpegInstaller from '@ffmpeg-installer/ffmpeg';
import type { AudiobookChapter } from '@bookorbit/types';
const execFile = promisify(execFileCallback);
export interface AudioExtractResult {
title: string | null;
authors: { name: string; sortName: string | null }[];
@@ -18,120 +19,122 @@ export interface AudioExtractResult {
coverBytes: Buffer | null;
}
interface FfprobeStream {
codec_type: string;
codec_name: string;
tags?: Record<string, string>;
}
interface FfprobeChapter {
start_time: string;
tags?: Record<string, string>;
}
interface FfprobeOutput {
format: {
duration?: string;
tags?: Record<string, string>;
};
streams?: FfprobeStream[];
chapters?: FfprobeChapter[];
}
export async function extractAudioMetadata(absolutePath: string): Promise<AudioExtractResult> {
try {
const metadata = await mm.parseFile(absolutePath, {
duration: true,
skipCovers: false,
includeChapters: true,
});
const { common, format } = metadata;
const { stdout } = await execFile(ffprobeInstaller.path, [
'-v',
'quiet',
'-print_format',
'json',
'-show_format',
'-show_chapters',
'-show_streams',
absolutePath,
]);
const data: FfprobeOutput = JSON.parse(stdout);
const tags = normalizeTags(data.format.tags ?? {});
const streams = data.streams ?? [];
const chapters = data.chapters ?? [];
// albumartist = book author, artist = narrator.
// If only artist is set, it is the author.
const rawAlbumArtist = common.albumartist;
const rawArtists = common.artists ?? (common.artist ? [common.artist] : []);
const rawAlbumArtist = tags.albumartist ?? tags.album_artist ?? null;
const rawArtist = tags.artist ?? null;
const authors = rawAlbumArtist ? splitArtists(rawAlbumArtist) : rawArtists.flatMap(splitArtists);
const authorNames = rawAlbumArtist ? splitArtists(rawAlbumArtist) : rawArtist ? splitArtists(rawArtist) : [];
const narratorNames = rawAlbumArtist && rawArtist ? splitArtists(rawArtist) : [];
const narrators = rawAlbumArtist ? rawArtists.flatMap(splitArtists) : [];
// Album tag is the audiobook title; fall back to track title.
const title = tags.album ?? tags.title ?? null;
// Album tag is the book title for audiobooks; fall back to track title.
const title = common.album ?? common.title ?? null;
const publisher = tags.publisher ?? null;
const publishedYear = parseYear(tags.date ?? tags.year ?? null);
const description = tags.comment ?? tags.description ?? null;
const language = resolveLanguage(tags, streams);
const durationSeconds = data.format.duration ? Math.round(parseFloat(data.format.duration)) : null;
const publisher = common.label?.[0] ?? null;
const publishedYear = common.year ?? null;
const description = extractCommentText(common.comment as unknown);
const language = common.language ?? null;
const mappedChapters: AudiobookChapter[] = chapters.map((ch) => ({
title: ch.tags?.title ?? '',
startMs: Math.round(parseFloat(ch.start_time) * 1000),
}));
const durationSeconds = format.duration !== undefined ? Math.round(format.duration) : null;
// music-metadata fails to read M4B chapter tracks reliably (iterates chunks not samples).
// Try mediainfo first; fall back to music-metadata's chapter data.
let chapters: AudiobookChapter[] = await extractChaptersWithMediainfo(absolutePath);
if (chapters.length === 0) {
chapters = (format.chapters ?? [])
.filter((ch) => ch.sampleOffset != null || ch.start != null)
.map((ch) => {
const startSec = ch.sampleOffset != null ? ch.sampleOffset / (format.sampleRate ?? 44100) : ch.start;
return { title: ch.title, startMs: Math.round(startSec * 1000) };
});
}
let coverBytes: Buffer | null = null;
const picture = common.picture?.[0];
if (picture?.data) {
coverBytes = Buffer.from(picture.data);
}
const coverBytes = await extractCoverBytes(absolutePath, streams);
return {
title,
authors: authors.map((name) => ({ name, sortName: null })),
narrators,
authors: authorNames.map((name) => ({ name, sortName: null })),
narrators: narratorNames,
publisher,
publishedYear,
description,
language,
durationSeconds,
chapters,
chapters: mappedChapters,
coverBytes,
};
} catch {
return {
title: null,
authors: [],
narrators: [],
publisher: null,
publishedYear: null,
description: null,
language: null,
durationSeconds: null,
chapters: [],
coverBytes: null,
};
return emptyResult();
}
}
export async function parseAudioDuration(absolutePath: string): Promise<number | null> {
try {
const metadata = await mm.parseFile(absolutePath, { duration: true, skipCovers: true });
if (metadata.format.duration === undefined) return null;
return Math.round(metadata.format.duration);
const { stdout } = await execFile(ffprobeInstaller.path, ['-v', 'quiet', '-print_format', 'json', '-show_format', absolutePath]);
const data: FfprobeOutput = JSON.parse(stdout);
if (!data.format.duration) return null;
return Math.round(parseFloat(data.format.duration));
} catch {
return null;
}
}
async function extractChaptersWithMediainfo(absolutePath: string): Promise<AudiobookChapter[]> {
let fileHandle: Awaited<ReturnType<typeof open>> | null = null;
const mi = await mediaInfoFactory({ format: 'object' });
try {
fileHandle = await open(absolutePath, 'r');
const { size } = await fileHandle.stat();
const readChunk = async (chunkSize: number, offset: number): Promise<Uint8Array> => {
const buf = new Uint8Array(chunkSize);
await fileHandle!.read(buf, 0, chunkSize, offset);
return buf;
};
const result = await mi.analyzeData(() => size, readChunk);
const menus = (result.media?.track ?? []).filter((t) => t['@type'] === 'Menu' && t.extra);
const extra = menus.flatMap((t) => Object.entries(t.extra as Record<string, string>));
return extra
.filter(([key]) => /^_\d{2}_\d{2}_\d{2}_\d{3}$/.test(key))
.map(([key, title]) => ({ title, startMs: parseMediainfoTimestamp(key) }))
.sort((a, b) => a.startMs - b.startMs);
} catch {
return [];
} finally {
await fileHandle?.close();
mi.close();
}
async function extractCoverBytes(absolutePath: string, streams: FfprobeStream[]): Promise<Buffer | null> {
const hasEmbeddedImage = streams.some((s) => s.codec_type === 'video');
if (!hasEmbeddedImage) return null;
return new Promise<Buffer | null>((resolve) => {
const chunks: Buffer[] = [];
const proc = spawn(
ffmpegInstaller.path,
['-y', '-i', absolutePath, '-map', '0:v', '-frames:v', '1', '-f', 'image2pipe', '-vcodec', 'mjpeg', 'pipe:1'],
{ stdio: ['ignore', 'pipe', 'ignore'] },
);
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk));
proc.on('close', (code) => {
if (code === 0 && chunks.length > 0) {
resolve(Buffer.concat(chunks));
} else {
resolve(null);
}
});
proc.on('error', () => resolve(null));
});
}
function parseMediainfoTimestamp(key: string): number {
// Key format: _HH_MM_SS_mmm (e.g. _00_13_24_850 = 13 min 24 sec 850 ms)
const [h, m, s, ms] = key.replace(/^_/, '').split('_').map(Number);
return h * 3_600_000 + m * 60_000 + s * 1_000 + ms;
function normalizeTags(tags: Record<string, string>): Record<string, string> {
return Object.fromEntries(Object.entries(tags).map(([k, v]) => [k.toLowerCase(), v]));
}
function splitArtists(raw: string): string[] {
@@ -141,18 +144,33 @@ function splitArtists(raw: string): string[] {
.filter(Boolean);
}
function extractCommentText(rawComments: unknown): string | null {
if (!Array.isArray(rawComments)) return null;
function parseYear(raw: string | null): number | null {
if (!raw) return null;
const match = raw.match(/\d{4}/);
return match ? parseInt(match[0], 10) : null;
}
for (const comment of rawComments) {
if (typeof comment === 'string' && comment.trim().length > 0) {
return comment;
}
if (typeof comment === 'object' && comment !== null && 'text' in comment && typeof comment.text === 'string' && comment.text.trim().length > 0) {
return comment.text;
function resolveLanguage(tags: Record<string, string>, streams: FfprobeStream[]): string | null {
if (tags.language) return tags.language;
for (const stream of streams) {
if (stream.codec_type === 'audio' && stream.tags?.language) {
return stream.tags.language;
}
}
return null;
}
function emptyResult(): AudioExtractResult {
return {
title: null,
authors: [],
narrators: [],
publisher: null,
publishedYear: null,
description: null,
language: null,
durationSeconds: null,
chapters: [],
coverBytes: null,
};
}
@@ -229,16 +229,23 @@ describe('RecommendationRepository', () => {
it('queries series books with expected shape when input is valid', async () => {
const rows = [
{ bookId: 1, title: 'Book 1', seriesIndex: 1 },
{ bookId: 2, title: 'Book 2', seriesIndex: 2 },
{ bookId: 1, title: 'Book 1', seriesIndex: 1, coverSource: 'extracted' },
{ bookId: 2, title: 'Book 2', seriesIndex: 2, coverSource: null },
];
const { db, select, chains } = makeDb([{ terminal: 'limit', result: rows }]);
const authorRows = [{ bookId: 1, name: 'Frank Herbert' }];
const { db, select, chains } = makeDb([
{ terminal: 'limit', result: rows },
{ terminal: 'where', result: authorRows },
]);
const repo = new RecommendationRepository(db);
const result = await repo.findSeriesBooks('Dune', [3, 4]);
expect(result).toEqual(rows);
expect(select).toHaveBeenCalledTimes(1);
expect(result).toEqual([
{ bookId: 1, title: 'Book 1', seriesIndex: 1, coverSource: 'extracted', authorNames: ['Frank Herbert'] },
{ bookId: 2, title: 'Book 2', seriesIndex: 2, coverSource: null, authorNames: [] },
]);
expect(select).toHaveBeenCalledTimes(2);
expect(chains[0].from).toHaveBeenCalledTimes(1);
expect(chains[0].leftJoin).toHaveBeenCalledTimes(1);
expect(chains[0].where).toHaveBeenCalledTimes(1);
@@ -246,6 +253,19 @@ describe('RecommendationRepository', () => {
expect(chains[0].limit).toHaveBeenCalledWith(50);
});
it('returns series books with empty authorNames when no authors exist', async () => {
const rows = [{ bookId: 5, title: 'Solo Book', seriesIndex: null, coverSource: null }];
const { db } = makeDb([
{ terminal: 'limit', result: rows },
{ terminal: 'where', result: [] },
]);
const repo = new RecommendationRepository(db);
const result = await repo.findSeriesBooks('Lone Series', [1]);
expect(result).toEqual([{ bookId: 5, title: 'Solo Book', seriesIndex: null, coverSource: null, authorNames: [] }]);
});
it('returns empty author books when libraryIds is empty', async () => {
const { db, select } = makeDb([]);
const repo = new RecommendationRepository(db);
@@ -257,19 +277,35 @@ describe('RecommendationRepository', () => {
});
it('queries author books with expected shape when input is valid', async () => {
const rows = [{ bookId: 10, title: 'Other Book', sharedAuthors: 2 }];
const rows = [{ bookId: 10, title: 'Other Book', sharedAuthors: 2, coverSource: 'extracted' }];
const authorRows = [{ bookId: 10, name: 'Terry Pratchett' }];
const { db, select, chains } = makeDb([
{ terminal: 'where', result: undefined },
{ terminal: 'limit', result: rows },
{ terminal: 'where', result: authorRows },
]);
const repo = new RecommendationRepository(db);
const result = await repo.findAuthorBooks(1, [3]);
expect(result).toEqual(rows);
expect(select).toHaveBeenCalledTimes(2);
expect(result).toEqual([{ bookId: 10, title: 'Other Book', coverSource: 'extracted', authorNames: ['Terry Pratchett'] }]);
expect(select).toHaveBeenCalledTimes(3);
expect(chains[1].innerJoin).toHaveBeenCalledTimes(1);
expect(chains[1].leftJoin).toHaveBeenCalledTimes(1);
expect(chains[1].limit).toHaveBeenCalledWith(25);
});
it('returns author books with empty authorNames when no authors exist', async () => {
const rows = [{ bookId: 7, title: 'Anonymous Work', sharedAuthors: 1, coverSource: null }];
const { db } = makeDb([
{ terminal: 'where', result: undefined },
{ terminal: 'limit', result: rows },
{ terminal: 'where', result: [] },
]);
const repo = new RecommendationRepository(db);
const result = await repo.findAuthorBooks(1, [1]);
expect(result).toEqual([{ bookId: 7, title: 'Anonymous Work', coverSource: null, authorNames: [] }]);
});
});
@@ -15,11 +15,15 @@ export interface SeriesBookRow {
bookId: number;
title: string | null;
seriesIndex: number | null;
coverSource: string | null;
authorNames: string[];
}
export interface AuthorBookRow {
bookId: number;
title: string | null;
coverSource: string | null;
authorNames: string[];
}
export interface AnnCandidate {
@@ -132,17 +136,32 @@ export class RecommendationRepository {
const normalized = seriesName.trim().toLowerCase();
return this.db
const rows = await this.db
.select({
bookId: books.id,
title: bookMetadata.title,
seriesIndex: bookMetadata.seriesIndex,
coverSource: bookMetadata.coverSource,
})
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(and(inArray(books.libraryId, libraryIds), sql`lower(trim(${bookMetadata.seriesName})) = ${normalized}`))
.orderBy(sql`${bookMetadata.seriesIndex} ASC NULLS LAST`, asc(bookMetadata.title), asc(books.id))
.limit(SERIES_BOOKS_LIMIT);
const bookIds = rows.map((r) => r.bookId);
const authorRows =
bookIds.length === 0
? []
: await this.db
.select({ bookId: bookAuthors.bookId, name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(inArray(bookAuthors.bookId, bookIds));
const authorsByBook = this.groupNamesByBook(authorRows);
return rows.map((r) => ({ ...r, authorNames: authorsByBook.get(r.bookId) ?? [] }));
}
async findAuthorBooks(bookId: number, libraryIds: number[]): Promise<AuthorBookRow[]> {
@@ -150,19 +169,34 @@ export class RecommendationRepository {
const authorIds = this.db.select({ authorId: bookAuthors.authorId }).from(bookAuthors).where(eq(bookAuthors.bookId, bookId));
return this.db
const rows = await this.db
.select({
bookId: books.id,
title: bookMetadata.title,
coverSource: bookMetadata.coverSource,
sharedAuthors: sql<number>`count(*)::int`.as('shared_authors'),
})
.from(bookAuthors)
.innerJoin(books, eq(books.id, bookAuthors.bookId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(and(inArray(bookAuthors.authorId, authorIds), inArray(books.libraryId, libraryIds), ne(books.id, bookId)))
.groupBy(books.id, bookMetadata.title)
.groupBy(books.id, bookMetadata.title, bookMetadata.coverSource)
.orderBy(desc(sql`shared_authors`), asc(bookMetadata.title), asc(books.id))
.limit(AUTHOR_BOOKS_LIMIT);
const bookIds = rows.map((r) => r.bookId);
const authorRows =
bookIds.length === 0
? []
: await this.db
.select({ bookId: bookAuthors.bookId, name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(inArray(bookAuthors.bookId, bookIds));
const authorsByBook = this.groupNamesByBook(authorRows);
return rows.map((r) => ({ bookId: r.bookId, title: r.title, coverSource: r.coverSource, authorNames: authorsByBook.get(r.bookId) ?? [] }));
}
private groupNamesByBook(rows: Array<{ bookId: number; name: string }>): Map<number, string[]> {
@@ -80,9 +80,9 @@ describe('RecommendationService', () => {
libraryService.findAll.mockResolvedValue([{ id: 7 }, { id: 9 }]);
recRepo.findAnnCandidates.mockResolvedValue([{ bookId: 91, cosineSim: 0.78, seriesName: null, rating: null }]);
recRepo.getCandidateMetadata.mockResolvedValue([{ bookId: 91, authorNames: [], genreTagNames: [] }]);
bookRepo.findRecommendationTitlesByBookIds.mockResolvedValue([{ id: 91, title: 'Fallback Match' }]);
bookRepo.findRecommendationTitlesByBookIds.mockResolvedValue([{ id: 91, title: 'Fallback Match', hasCover: false, authors: [] }]);
await expect(service.getRecommendations(55, makeUser())).resolves.toEqual([{ id: 91, title: 'Fallback Match' }]);
await expect(service.getRecommendations(55, makeUser())).resolves.toEqual([{ id: 91, title: 'Fallback Match', hasCover: false, authors: [] }]);
expect(embedder.embedBook).toHaveBeenCalledWith(55);
expect(recRepo.findAnnCandidates).toHaveBeenCalledWith([0.4, 0.6], 55, [7, 9]);
});
@@ -135,15 +135,15 @@ describe('RecommendationService', () => {
{ bookId: 200, authorNames: [], genreTagNames: [] },
]);
bookRepo.findRecommendationTitlesByBookIds.mockResolvedValue([
{ id: 200, title: 'Second' },
{ id: 100, title: 'First' },
{ id: 200, title: 'Second', hasCover: true, authors: [] },
{ id: 100, title: 'First', hasCover: false, authors: ['Frank Herbert'] },
]);
const result = await service.getRecommendations(9, makeUser());
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ id: 100, title: 'First' });
expect(result[1]).toEqual({ id: 200, title: 'Second' });
expect(result[0]).toEqual({ id: 100, title: 'First', hasCover: false, authors: ['Frank Herbert'] });
expect(result[1]).toEqual({ id: 200, title: 'Second', hasCover: true, authors: [] });
});
it('normalizes author and genre-tag metadata before similarity scoring', async () => {
@@ -166,15 +166,15 @@ describe('RecommendationService', () => {
{ bookId: 2, authorNames: [], genreTagNames: [] },
]);
bookRepo.findRecommendationTitlesByBookIds.mockResolvedValue([
{ id: 1, title: 'Token Match' },
{ id: 2, title: 'Cosine Only' },
{ id: 1, title: 'Token Match', hasCover: true, authors: ['frank herbert'] },
{ id: 2, title: 'Cosine Only', hasCover: false, authors: [] },
]);
const result = await service.getRecommendations(13, makeUser());
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ id: 1, title: 'Token Match' });
expect(result[1]).toEqual({ id: 2, title: 'Cosine Only' });
expect(result[0]).toEqual({ id: 1, title: 'Token Match', hasCover: true, authors: ['frank herbert'] });
expect(result[1]).toEqual({ id: 2, title: 'Cosine Only', hasCover: false, authors: [] });
});
it('filters out ANN results that cannot be mapped to cards', async () => {
@@ -196,12 +196,12 @@ describe('RecommendationService', () => {
{ bookId: 10, authorNames: [], genreTagNames: [] },
{ bookId: 11, authorNames: [], genreTagNames: [] },
]);
bookRepo.findRecommendationTitlesByBookIds.mockResolvedValue([{ id: 11, title: 'Only Card' }]);
bookRepo.findRecommendationTitlesByBookIds.mockResolvedValue([{ id: 11, title: 'Only Card', hasCover: true, authors: [] }]);
const result = await service.getRecommendations(2, makeUser());
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ id: 11, title: 'Only Card' });
expect(result[0]).toEqual({ id: 11, title: 'Only Card', hasCover: true, authors: [] });
});
it('returns empty recommendations when user has no accessible libraries with ANN candidates', async () => {
@@ -244,7 +244,9 @@ describe('RecommendationService', () => {
recRepo.findAnnCandidates.mockResolvedValue(candidates);
recRepo.getCandidateMetadata.mockResolvedValue(candidates.map((c) => ({ bookId: c.bookId, authorNames: [], genreTagNames: [] })));
bookRepo.findRecommendationTitlesByBookIds.mockResolvedValue(Array.from({ length: 30 }, (_, i) => ({ id: i + 1, title: `Book ${i + 1}` })));
bookRepo.findRecommendationTitlesByBookIds.mockResolvedValue(
Array.from({ length: 30 }, (_, i) => ({ id: i + 1, title: `Book ${i + 1}`, hasCover: false, authors: [] })),
);
const result = await service.getRecommendations(8, makeUser());
@@ -285,17 +287,17 @@ describe('RecommendationService', () => {
recRepo.getSeriesName.mockResolvedValue('Stormlight Archive');
libraryService.findAccessibleLibraryIds.mockResolvedValue([5, 6]);
recRepo.findSeriesBooks.mockResolvedValue([
{ bookId: 1, title: 'The Way of Kings', seriesIndex: 1 },
{ bookId: 2, title: 'Words of Radiance', seriesIndex: 2 },
{ bookId: 3, title: 'Oathbringer', seriesIndex: 3 },
{ bookId: 1, title: 'The Way of Kings', seriesIndex: 1, coverSource: 'extracted', authorNames: ['Brandon Sanderson'] },
{ bookId: 2, title: 'Words of Radiance', seriesIndex: 2, coverSource: null, authorNames: [] },
{ bookId: 3, title: 'Oathbringer', seriesIndex: 3, coverSource: 'custom', authorNames: ['Brandon Sanderson'] },
]);
const result = await service.getSeriesBooks(2, makeUser());
expect(result).toEqual([
{ id: 1, title: 'The Way of Kings', seriesIndex: 1 },
{ id: 2, title: 'Words of Radiance', seriesIndex: 2 },
{ id: 3, title: 'Oathbringer', seriesIndex: 3 },
{ id: 1, title: 'The Way of Kings', seriesIndex: 1, hasCover: true, authors: ['Brandon Sanderson'] },
{ id: 2, title: 'Words of Radiance', seriesIndex: 2, hasCover: false, authors: [] },
{ id: 3, title: 'Oathbringer', seriesIndex: 3, hasCover: true, authors: ['Brandon Sanderson'] },
]);
expect(recRepo.findSeriesBooks).toHaveBeenCalledWith('Stormlight Archive', [5, 6]);
});
@@ -337,15 +339,15 @@ describe('RecommendationService', () => {
bookRepo.findLibraryIdByBookId.mockResolvedValue(5);
libraryService.findAccessibleLibraryIds.mockResolvedValue([5]);
recRepo.findAuthorBooks.mockResolvedValue([
{ bookId: 10, title: 'Other Book A' },
{ bookId: 20, title: 'Other Book B' },
{ bookId: 10, title: 'Other Book A', coverSource: 'extracted', authorNames: ['Jane Austen'] },
{ bookId: 20, title: 'Other Book B', coverSource: null, authorNames: [] },
]);
const result = await service.getAuthorBooks(1, makeUser());
expect(result).toEqual([
{ id: 10, title: 'Other Book A' },
{ id: 20, title: 'Other Book B' },
{ id: 10, title: 'Other Book A', hasCover: true, authors: ['Jane Austen'] },
{ id: 20, title: 'Other Book B', hasCover: false, authors: [] },
]);
expect(recRepo.findAuthorBooks).toHaveBeenCalledWith(1, [5]);
});
@@ -86,7 +86,7 @@ export class RecommendationService {
const rowMap = new Map(rows.map((row) => [row.id, row]));
const recommendations = rescored
.map((rescoredCandidate) => rowMap.get(rescoredCandidate.bookId))
.filter((row): row is { id: number; title: string | null } => row != null);
.filter((row): row is { id: number; title: string | null; hasCover: boolean; authors: string[] } => row != null);
this.logger.log(
`[${RECOMMENDATION_EVENT}] [end] bookId=${bookId} userId=${user.id} libraryId=${libraryId} durationMs=${Date.now() - startedAt} accessibleLibraryCount=${accessibleLibraryIds.length} candidateCount=${candidates.length} rescoredCount=${rescored.length} resultCount=${recommendations.length} - recommendation lookup completed`,
@@ -126,7 +126,13 @@ export class RecommendationService {
`[${SERIES_BOOKS_EVENT}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} seriesName="${meta}" resultCount=${rows.length} - series books lookup completed`,
);
return rows.map((r) => ({ id: r.bookId, title: r.title, seriesIndex: r.seriesIndex }));
return rows.map((r) => ({
id: r.bookId,
title: r.title,
seriesIndex: r.seriesIndex,
hasCover: r.coverSource !== null,
authors: r.authorNames,
}));
} catch (err) {
const { errorClass, errorMessage } = this.parseError(err);
this.logger.error(
@@ -152,7 +158,7 @@ export class RecommendationService {
`[${AUTHOR_BOOKS_EVENT}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} resultCount=${rows.length} - author books lookup completed`,
);
return rows.map((r) => ({ id: r.bookId, title: r.title }));
return rows.map((r) => ({ id: r.bookId, title: r.title, hasCover: r.coverSource !== null, authors: r.authorNames }));
} catch (err) {
const { errorClass, errorMessage } = this.parseError(err);
this.logger.error(