From 57d91ebea127b58c3b492ea573803b2bfb566d5a Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sun, 10 May 2026 23:14:11 +0000 Subject: [PATCH] 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 --- .../author/views/AuthorDetailView.vue | 6 + .../book/components/BookCoverCard.vue | 9 +- .../book/components/VirtualBookGrid.vue | 2 + .../components/detail/BookCarousel.test.ts | 150 +++++ .../book/components/detail/BookCarousel.vue | 11 + .../detail/tabs/CoverEditorPanel.vue | 23 +- .../detail/tabs/CoverSearchDrawer.test.ts | 346 +++++++++++ .../detail/tabs/CoverSearchDrawer.vue | 37 +- .../detail/tabs/MetadataDiffPanel.vue | 9 + .../detail/tabs/MetadataDiffRow.vue | 18 +- .../detail/tabs/MetadataResultCard.vue | 8 +- .../reader/audiobook/AudiobookReaderView.vue | 19 +- .../series/views/SeriesDetailView.vue | 1 + client/src/views/HomeView.vue | 1 + packages/types/src/book.ts | 4 + pnpm-lock.yaml | 321 ++++++---- server/package.json | 4 +- .../src/modules/book/book.repository.test.ts | 45 +- server/src/modules/book/book.repository.ts | 34 +- .../extractors/audio.extractor.test.ts | 575 +++++++++++------- .../metadata/extractors/audio.extractor.ts | 210 ++++--- .../recommendation.repository.test.ts | 52 +- .../recommendation.repository.ts | 40 +- .../recommendation.service.test.ts | 48 +- .../recommendation/recommendation.service.ts | 12 +- 25 files changed, 1491 insertions(+), 494 deletions(-) create mode 100644 client/src/features/book/components/detail/BookCarousel.test.ts create mode 100644 client/src/features/book/components/detail/tabs/CoverSearchDrawer.test.ts diff --git a/client/src/features/author/views/AuthorDetailView.vue b/client/src/features/author/views/AuthorDetailView.vue index 041965e6d..d23ff0e6f 100644 --- a/client/src/features/author/views/AuthorDetailView.vue +++ b/client/src/features/author/views/AuthorDetailView.vue @@ -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" />
diff --git a/client/src/features/book/components/BookCoverCard.vue b/client/src/features/book/components/BookCoverCard.vue index 46fd014cf..39ebcdc0c 100644 --- a/client/src/features/book/components/BookCoverCard.vue +++ b/client/src/features/book/components/BookCoverCard.vue @@ -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) { Edit Metadata - + Refresh Metadata diff --git a/client/src/features/book/components/VirtualBookGrid.vue b/client/src/features/book/components/VirtualBookGrid.vue index 18db55d67..bd854a13a 100644 --- a/client/src/features/book/components/VirtualBookGrid.vue +++ b/client/src/features/book/components/VirtualBookGrid.vue @@ -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(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)" />
diff --git a/client/src/features/book/components/detail/BookCarousel.test.ts b/client/src/features/book/components/detail/BookCarousel.test.ts new file mode 100644 index 000000000..cdb3f59b7 --- /dev/null +++ b/client/src/features/book/components/detail/BookCarousel.test.ts @@ -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 { + 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) + }) +}) diff --git a/client/src/features/book/components/detail/BookCarousel.vue b/client/src/features/book/components/detail/BookCarousel.vue index e74dd53b6..4dcec30e4 100644 --- a/client/src/features/book/components/detail/BookCarousel.vue +++ b/client/src/features/book/components/detail/BookCarousel.vue @@ -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))" > + () @@ -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))
- + +