mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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">
|
||||
|
||||
@@ -805,6 +805,7 @@ async function handleToggleCollapse() {
|
||||
:new-book-ids="newBookIds"
|
||||
@action="handleBookAction"
|
||||
@select="handleSelect"
|
||||
@update:book="handleTableBookUpdate"
|
||||
/>
|
||||
|
||||
<!-- List view -->
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Generated
+217
-104
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user