fix(books): respect library cover aspect ratio for combined media books

Books containing both ebook and audiobook formats were incorrectly rendered with square covers in dashboard widgets, series views, and cards because aspect ratio logic checked for any audio format file instead of relying on the configured cover aspect ratio or primary media profile.

Closes #712
This commit is contained in:
neon
2026-07-22 04:12:13 +00:00
parent d051039a2d
commit 544f4d5e53
46 changed files with 499 additions and 133 deletions
@@ -148,6 +148,7 @@ function makeResult(id: number, title = 'Prey'): GlobalSearchResult {
return {
id,
status: 'present',
coverAspectRatio: '2/3',
title,
authors: ['Author'],
seriesId: null,
@@ -137,6 +137,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
status: 'present',
coverAspectRatio: '2/3',
title: 'Dune',
authors: ['Frank Herbert'],
seriesId: null,
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { BookCard, BookFileRef, CoverAspectRatio } from '@bookorbit/types'
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@bookorbit/types'
import { FORMAT_TO_GROUP, getBookMediaProfile, READER_OPENABLE_FORMATS } from '@bookorbit/types'
import { getFormatColor } from '../lib/format-colors'
import { computed, inject, ref, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -77,8 +77,9 @@ const authorQuery = computed(() => props.book.authors[0] ?? null)
const readableFiles = computed(() => props.book.files.filter((f) => f.format && READER_OPENABLE_FORMATS.has(f.format)))
const primaryFile = computed(() => readableFiles.value.find((f) => f.role === 'primary') ?? readableFiles.value[0] ?? null)
const isAudiobook = computed(() => readableFiles.value.some((f) => FORMAT_TO_GROUP[f.format!] === 'audio'))
const isComic = computed(() => readableFiles.value.some((f) => FORMAT_TO_GROUP[f.format!] === 'cbx'))
const mediaProfile = computed(() => getBookMediaProfile(readableFiles.value))
const isAudiobook = computed(() => mediaProfile.value.primaryMediaKind === 'audiobook')
const isComic = computed(() => mediaProfile.value.primaryMediaKind === 'comic')
// For multi-file audiobooks, collapse all tracks into one representative entry.
// The audio reader loads the full track queue from the book, so opening any track is equivalent.
@@ -121,7 +122,9 @@ async function handleRefreshMetadata() {
const { hasPermission } = usePermissions()
const { cardOverlays, bookCoverDisplayMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, thumbnailClickAction } = useDisplaySettings()
const injectedCoverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const effectiveCoverAspectRatio = computed<CoverAspectRatio>(() => props.coverAspectRatio ?? injectedCoverAspectRatio.value)
const effectiveCoverAspectRatio = computed<CoverAspectRatio>(
() => props.coverAspectRatio ?? props.book.coverAspectRatio ?? injectedCoverAspectRatio.value,
)
const showSendDialog = ref(false)
const hasProgress = computed(() => props.book.readingProgress != null && props.book.readingProgress > 0)
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { BookCard, BookFileRef } from '@bookorbit/types'
import { FORMAT_TO_GROUP } from '@bookorbit/types'
import { getBookMediaProfile } from '@bookorbit/types'
import BookCoverArtwork from './BookCoverArtwork.vue'
import BookCoverSurface from './BookCoverSurface.vue'
import { api } from '@/lib/api'
@@ -90,8 +90,9 @@ const seriesLine = computed(() => {
const isMissing = computed(() => props.book.status === 'missing')
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
const mediaProfile = computed(() => getBookMediaProfile(props.book.files))
const isAudiobook = computed(() => mediaProfile.value.primaryMediaKind === 'audiobook')
const isComic = computed(() => mediaProfile.value.primaryMediaKind === 'comic')
const secondaryFiles = computed(() => props.book.files.filter((f) => f !== primaryFile.value))
const uniqueSecondaryFiles = computed(() => {
@@ -143,7 +144,8 @@ const { coverUrl } = useCoverVersions()
const coverSrc = computed(() => coverUrl(props.book.id, 'thumbnail', props.book.updatedAt ?? props.book.addedAt))
const { refreshing, refreshWithFeedback } = useRefreshMetadata()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const injectedCoverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const coverAspectRatio = computed(() => props.book.coverAspectRatio ?? injectedCoverAspectRatio.value)
function openFile(file: BookFileRef, mode?: 'peek') {
router.push({
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
import { getBookMediaProfile, type BookCard } from '@bookorbit/types'
import { Library } from '@lucide/vue'
import BookCoverArtwork from './BookCoverArtwork.vue'
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
@@ -21,7 +21,8 @@ const props = defineProps<{
const route = useRoute()
const router = useRouter()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const injectedCoverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const coverAspectRatio = computed(() => props.book.coverAspectRatio ?? injectedCoverAspectRatio.value)
const { coverUrl } = useCoverVersions()
const { seriesCardCoverMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, cardOverlays } = useDisplaySettings()
@@ -81,9 +82,9 @@ watch(
},
{ immediate: true },
)
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
const mediaProfile = computed(() => getBookMediaProfile(props.book.files))
const isAudiobook = computed(() => mediaProfile.value.primaryMediaKind === 'audiobook')
const isComic = computed(() => mediaProfile.value.primaryMediaKind === 'comic')
function handleCoverLoad(bookId: number) {
loadedCovers.value = new Set([...loadedCovers.value, bookId])
@@ -57,6 +57,7 @@ function makeBook(id: number, overrides: Partial<BookCard> = {}): BookCard {
return {
id,
status: 'present',
coverAspectRatio: '2/3',
title: `Book ${id}`,
authors: [],
seriesName: null,
@@ -174,10 +175,16 @@ describe('VirtualBookGrid', () => {
expect(updatedBook?.title).toBe('Updated')
})
it('renders audiobook cards 1.25x wider in static mode when audioCoverScale is set', () => {
it('scales square cover slots independently of media format in static mode', () => {
const books = [
makeBook(1, { files: [{ id: 11, format: 'epub', role: 'primary', sizeBytes: null }] }),
makeBook(2, { files: [{ id: 22, format: 'MP3', role: 'primary', sizeBytes: null }] }),
makeBook(1, {
coverAspectRatio: '2/3',
files: [
{ id: 11, format: 'epub', role: 'primary', sizeBytes: null },
{ id: 12, format: 'm4b', role: 'content', sizeBytes: null },
],
}),
makeBook(2, { coverAspectRatio: '1/1', files: [{ id: 22, format: 'epub', role: 'primary', sizeBytes: null }] }),
]
const wrapper = mount(VirtualBookGrid, {
@@ -186,7 +193,7 @@ describe('VirtualBookGrid', () => {
coverSize: 120,
gridGap: 12,
virtualized: false,
audioCoverScale: 1.25,
squareCoverScale: 1.25,
},
})
@@ -2,7 +2,7 @@
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useElementSize, useWindowSize, watchDebounced } from '@vueuse/core'
import { RecycleScroller } from 'vue-virtual-scroller'
import { isAudioFormat, type BookCard, type CoverAspectRatio, type JumpBucketKind } from '@bookorbit/types'
import type { BookCard, CoverAspectRatio, JumpBucketKind } from '@bookorbit/types'
import BookCoverCard from './BookCoverCard.vue'
import BookCoverSkeleton from './BookCoverSkeleton.vue'
import CollapsedSeriesCard from './CollapsedSeriesCard.vue'
@@ -21,7 +21,7 @@ const props = withDefaults(
isSelected?: (bookId: number) => boolean
newBookIds?: Set<number>
virtualized?: boolean
audioCoverScale?: number
squareCoverScale?: number
railGutter?: boolean
railGutterKind?: JumpBucketKind | null
}>(),
@@ -30,7 +30,7 @@ const props = withDefaults(
isSelected: undefined,
newBookIds: () => new Set<number>(),
virtualized: true,
audioCoverScale: 1,
squareCoverScale: 1,
railGutter: false,
railGutterKind: null,
},
@@ -86,7 +86,7 @@ watchDebounced(
const coverPx = computed(() => asPositiveInt(props.coverSize, 140))
const gapPx = computed(() => asPositiveInt(props.gridGap, 20))
const audioCoverScale = computed(() => normalizeScale(props.audioCoverScale))
const squareCoverScale = computed(() => normalizeScale(props.squareCoverScale))
const availableWidth = computed(() => {
if (settledWidth.value > 0) return settledWidth.value
@@ -143,7 +143,7 @@ const staticVariableWrapStyle = computed(() => ({
gap: `${gapPx.value}px`,
}))
const useVariableStaticWidths = computed(() => !props.virtualized && audioCoverScale.value > 1)
const useVariableStaticWidths = computed(() => !props.virtualized && squareCoverScale.value > 1)
const staticBooks = computed(() => props.books.filter((slot): slot is BookCard => !isBookPlaceholder(slot)))
@@ -151,19 +151,14 @@ function asBook(slot: BookSlot): BookCard {
return slot as BookCard
}
function isAudiobook(book: BookCard): boolean {
return book.files.some((file) => (file.format ? isAudioFormat(file.format) : false))
}
function staticItemStyle(book: BookCard): { width: string; maxWidth: string } {
const scale = isAudiobook(book) ? audioCoverScale.value : 1
const scale = book.coverAspectRatio === '1/1' ? squareCoverScale.value : 1
const width = Math.max(1, Math.round(coverPx.value * scale))
return { width: `${width}px`, maxWidth: '100%' }
}
function staticCoverAspectRatio(book: BookCard): CoverAspectRatio {
if (isAudiobook(book)) return '1/1'
return coverAspectRatio.value
return book.coverAspectRatio ?? coverAspectRatio.value
}
function handleScrollerUpdate(startIndex: number, endIndex: number) {
@@ -86,6 +86,7 @@ afterEach(() => {
const missingBook: BookCard = {
id: 1,
status: 'missing',
coverAspectRatio: '2/3',
title: 'Gone Book',
authors: ['Test Author'],
seriesName: null,
@@ -116,6 +117,7 @@ const missingBook: BookCard = {
const presentBook: BookCard = {
id: 2,
status: 'present',
coverAspectRatio: '2/3',
title: 'Available Book',
authors: ['Test Author'],
seriesName: null,
@@ -180,6 +182,13 @@ describe('BookCoverCard — missing state', () => {
})
describe('BookCoverCard — cover aspect override', () => {
it('uses the book library ratio over an unrelated injected view ratio', () => {
const wrapper = mountCard({ ...presentBookWithCover, coverAspectRatio: '1/1' }, '2/3')
const style = wrapper.find('[style*="aspect-ratio"]').attributes('style') ?? ''
expect(style.includes('aspect-ratio: 1 / 1') || style.includes('aspect-ratio: 1/1')).toBe(true)
})
it('uses explicit coverAspectRatio prop over injected ratio', () => {
const wrapper = mountCard(presentBookWithCover, '2/3', '1/1')
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
@@ -344,6 +353,20 @@ describe('BookCoverCard — cover style preferences', () => {
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
expect(coverDiv.attributes('data-cover-spine')).toBe('off')
})
it('keeps ebook spine behavior when a secondary audiobook format is available', async () => {
const wrapper = mountCard({
...presentBook,
files: [
{ id: 10, format: 'epub', role: 'primary', sizeBytes: null },
{ id: 12, format: 'm4b', role: 'content', sizeBytes: null },
],
})
bookSpineOverlay.value = 'strong'
await nextTick()
expect(wrapper.find('[style*="aspect-ratio"]').attributes('data-cover-spine')).toBe('strong')
})
})
describe('BookCoverCard — placeholder state', () => {
@@ -52,6 +52,7 @@ afterEach(() => {
const missingBook: BookCard = {
id: 1,
status: 'missing',
coverAspectRatio: '2/3',
title: 'Gone Book',
authors: ['Test Author'],
seriesName: null,
@@ -82,6 +83,7 @@ const missingBook: BookCard = {
const presentBook: BookCard = {
id: 2,
status: 'present',
coverAspectRatio: '2/3',
title: 'Available Book',
authors: ['Test Author'],
seriesName: null,
@@ -221,6 +221,7 @@ describe('BookTableReadButtonCell', () => {
return {
id: 42,
status,
coverAspectRatio: '2/3',
title: 'Example Book',
authors: ['Author'],
seriesName: null,
@@ -51,6 +51,7 @@ function makeBook(overrides?: Partial<BookCard>): BookCard {
return {
id: 1,
status: 'present',
coverAspectRatio: '2/3',
title: 'Book One',
authors: ['Author A'],
seriesId: 42,
@@ -46,6 +46,7 @@ function makeBook(overrides: Partial<CarouselBook> = {}): CarouselBook {
return {
id: 1,
title: 'Dune',
coverAspectRatio: '2/3',
hasCover: true,
authors: ['Frank Herbert'],
seriesIndex: null,
@@ -119,18 +120,27 @@ describe('BookCarousel', () => {
expect(cover.attributes('style')).toContain('aspect-ratio: 2/3')
})
it('uses square ratio for audiobook cards', () => {
const wrapper = mountCarousel([makeBook({ isAudiobook: true })])
it('keeps audiobook cards portrait when configured portrait', () => {
const wrapper = mountCarousel([makeBook({ isAudiobook: true, coverAspectRatio: '2/3' })])
const cover = wrapper.find('.book-cover-surface')
expect(cover.attributes('style')).toContain('aspect-ratio: 2/3')
})
it('uses square ratio for ebook cards when configured square', () => {
const wrapper = mountCarousel([makeBook({ isAudiobook: false, coverAspectRatio: '1/1' })])
const cover = wrapper.find('.book-cover-surface')
expect(cover.attributes('style')).toContain('aspect-ratio: 1/1')
})
it('renders audiobook cards wider than non-audiobook cards', () => {
const wrapper = mountCarousel([makeBook({ id: 1, isAudiobook: false }), makeBook({ id: 2, isAudiobook: true })])
const nonAudioCard = wrapper.find('[data-book-id="1"]')
const audioCard = wrapper.find('[data-book-id="2"]')
expect(nonAudioCard.classes()).toContain('w-30')
expect(audioCard.classes()).toContain('w-38')
it('renders square slots wider than portrait slots regardless of media type', () => {
const wrapper = mountCarousel([
makeBook({ id: 1, isAudiobook: true, coverAspectRatio: '2/3' }),
makeBook({ id: 2, isAudiobook: false, coverAspectRatio: '1/1' }),
])
const portraitCard = wrapper.find('[data-book-id="1"]')
const squareCard = wrapper.find('[data-book-id="2"]')
expect(portraitCard.classes()).toContain('w-30')
expect(squareCard.classes()).toContain('w-38')
})
it('renders an img when book.hasCover is true', () => {
@@ -6,11 +6,12 @@ import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
import BookCoverSurface from '@/features/book/components/BookCoverSurface.vue'
import { DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
import type { CoverAspectRatio } from '@bookorbit/types'
export interface CarouselBook {
id: number
title: string | null
coverAspectRatio: CoverAspectRatio
updatedAt?: string | null
seriesIndex?: number | null
hasCover: boolean
@@ -62,7 +63,7 @@ function isComic(book: CarouselBook): boolean {
}
function cardAspectRatio(book: CarouselBook): string {
return isAudiobook(book) ? '1/1' : DEFAULT_COVER_ASPECT_RATIO
return book.coverAspectRatio
}
watch(
@@ -119,7 +120,7 @@ defineExpose({ scroll })
:key="book.id"
:data-book-id="book.id"
class="shrink-0 text-left group animate-fade-up"
:class="isAudiobook(book) ? 'w-38' : 'w-30'"
:class="book.coverAspectRatio === '1/1' ? 'w-38' : 'w-30'"
:style="{ animationDelay: `${index * 40}ms` }"
@click="navigateToBook(book.id)"
>
@@ -8,6 +8,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
status: 'present',
coverAspectRatio: '2/3',
title: 'Book One',
authors: ['Author A'],
seriesName: null,
@@ -16,6 +16,7 @@ function makeBook(format: string | null): BookCard {
return {
id: 1,
status: 'present',
coverAspectRatio: '2/3',
title: 'Saga',
authors: [],
seriesName: 'Saga',
@@ -34,6 +34,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
status: 'present',
coverAspectRatio: '2/3',
title: 'Dune',
authors: [],
seriesName: null,
@@ -110,6 +111,7 @@ describe('tableColumnSchema', () => {
const book = {
id: 1,
status: 'present',
coverAspectRatio: '2/3',
title: 'Dune',
authors: ['Frank Herbert'],
seriesName: null,
@@ -78,6 +78,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
status: 'present',
coverAspectRatio: '2/3',
title: 'Test Book',
authors: ['Test Author'],
seriesName: null,
@@ -8,6 +8,7 @@ function makeBook(id: number): BookCard {
return {
id,
status: 'active',
coverAspectRatio: '2/3',
title: `Book ${id}`,
authors: [],
seriesName: null,
@@ -75,6 +75,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
status: 'present',
coverAspectRatio: '2/3',
title: 'Test Book',
authors: ['Test Author'],
seriesName: null,
@@ -14,6 +14,7 @@ function makeBook(id: number): BookCard {
return {
id,
status: 'active',
coverAspectRatio: '2/3',
title: `Book ${id}`,
authors: [],
seriesName: null,
@@ -27,6 +27,7 @@ function makeBook(id: number): BookCard {
return {
id,
status: 'active',
coverAspectRatio: '2/3',
title: `Book ${id}`,
authors: [],
seriesName: null,
@@ -27,6 +27,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
status: 'present',
coverAspectRatio: '2/3',
title: 'Test Book',
authors: ['Author One'],
seriesName: null,
@@ -20,6 +20,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
status: 'ok',
coverAspectRatio: '2/3',
title: 'Test Book',
authors: ['Author One'],
seriesName: 'My Series',
@@ -20,6 +20,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
status: 'present',
coverAspectRatio: '2/3',
title: 'Test Book',
authors: ['Author One'],
seriesName: null,
@@ -6,6 +6,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
status: 'present',
coverAspectRatio: '2/3',
title: 'Test Book',
authors: ['Author One'],
seriesName: null,
@@ -20,6 +20,7 @@ function makeBook(id: number): BookCard {
return {
id,
status: 'present',
coverAspectRatio: '2/3',
title: `Prey ${id}`,
authors: ['Author'],
seriesId: null,
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { getBookMediaKind, getBookMediaProfile, getPrimaryBookFile, normalizeCoverAspectRatio, type BookFileRef } from '@bookorbit/types'
function file(id: number, format: string | null, role: string): BookFileRef {
return { id, format, role, sizeBytes: null }
}
describe('book media profile', () => {
it('uses the explicit primary file regardless of file order', () => {
const files = [file(1, 'm4b', 'content'), file(2, 'epub', 'primary'), file(3, 'cbz', 'content')]
expect(getPrimaryBookFile(files)?.id).toBe(2)
expect(getBookMediaProfile(files)).toEqual({
primaryMediaKind: 'ebook',
hasEbook: true,
hasAudio: true,
hasComic: true,
})
})
it('falls back to the first formatted file when no primary file exists', () => {
const files = [file(1, null, 'content'), file(2, 'M4B', 'content'), file(3, 'epub', 'content')]
expect(getPrimaryBookFile(files)?.id).toBe(2)
expect(getBookMediaProfile(files).primaryMediaKind).toBe('audiobook')
})
it.each([
[' EPUB ', 'ebook'],
['m4A', 'audiobook'],
['CB7', 'comic'],
[null, 'unknown'],
] as const)('classifies %s as %s', (format, expected) => {
expect(getBookMediaKind(format)).toBe(expected)
})
it('reports an empty book without inventing media capabilities', () => {
expect(getBookMediaProfile([])).toEqual({
primaryMediaKind: 'unknown',
hasEbook: false,
hasAudio: false,
hasComic: false,
})
})
})
describe('cover aspect ratio normalization', () => {
it.each([
['1/1', '1/1'],
['2/3', '2/3'],
['invalid', '2/3'],
[null, '2/3'],
] as const)('normalizes %s to %s', (value, expected) => {
expect(normalizeCoverAspectRatio(value)).toBe(expected)
})
})
@@ -6,6 +6,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
status: 'ok',
coverAspectRatio: '2/3',
title: 'Test Book',
authors: ['Author One'],
seriesName: null,
@@ -77,15 +77,16 @@ import { useDashboardScroller } from '../composables/useDashboardScroller'
const mockUseDashboardScroller = vi.mocked(useDashboardScroller)
function makeBook(id: number, format: string): BookCard {
function makeBook(id: number, format: string, coverAspectRatio: BookCard['coverAspectRatio'] = '2/3'): BookCard {
return {
id,
status: 'present',
coverAspectRatio,
title: `Book ${id}`,
authors: [],
seriesName: null,
seriesIndex: null,
files: [{ id, format, role: 'content', sizeBytes: 123 }],
files: [{ id, format, role: 'primary', sizeBytes: 123 }],
publishedDate: null,
publishedYear: null,
language: null,
@@ -181,8 +182,8 @@ describe('DashboardScroller', () => {
expect(refresh).toHaveBeenCalledTimes(1)
})
it('renders book cards with count and cover shape based on audio format', () => {
const wrapper = mountScroller({ type: 'continue-listening', books: [makeBook(1, 'm4b'), makeBook(2, 'epub')] })
it('renders cover slots from library configuration instead of media format', () => {
const wrapper = mountScroller({ type: 'continue-listening', books: [makeBook(1, 'epub', '1/1'), makeBook(2, 'm4b', '2/3')] })
const cards = wrapper.findAll('.book-card')
expect(wrapper.text()).toContain('2')
@@ -191,6 +192,16 @@ describe('DashboardScroller', () => {
expect(cards[1]?.attributes('data-aspect')).toBe('2/3')
})
it('keeps a mixed ebook and audiobook portrait when its library is portrait', () => {
const mixedBook = makeBook(1, 'epub', '2/3')
mixedBook.files.push({ id: 2, format: 'm4b', role: 'content', sizeBytes: 456 })
const wrapper = mountScroller({ type: 'want-to-read', books: [mixedBook] })
expect(wrapper.get('.book-card').attributes('data-aspect')).toBe('2/3')
expect(wrapper.get('.book-card').element.parentElement?.classList.contains('w-[120px]')).toBe(true)
})
it('opens related book actions from card events', async () => {
const wrapper = mountScroller({ type: 'want-to-read', books: [makeBook(42, 'epub')] })
@@ -3,7 +3,7 @@ import { computed, ref, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import { Aperture, BookMarked, BookmarkPlus, ChevronLeft, ChevronRight, Headphones, ListOrdered, RefreshCw, Shuffle, Sparkles } from '@lucide/vue'
import { isAudioFormat, type BookCard, type CoverAspectRatio, type ScrollerType } from '@bookorbit/types'
import type { BookCard, ScrollerType } from '@bookorbit/types'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
import BookQuickView from '@/features/book/components/BookQuickView.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
@@ -44,8 +44,8 @@ const typeIcon = computed(() => {
})
const SKELETONS = Array.from({ length: 8 })
const DEFAULT_COVER_WIDTH_CLASS = 'w-[120px]'
const SQUARE_AUDIOBOOK_COVER_WIDTH_CLASS = 'w-[150px]'
const PORTRAIT_COVER_WIDTH_CLASS = 'w-[120px]'
const SQUARE_COVER_WIDTH_CLASS = 'w-[150px]'
type BookActionType = 'quick-view' | 'edit-metadata' | 'add-to-collection' | 'delete'
@@ -81,16 +81,8 @@ function handleBookAction(book: BookCard, action: BookActionType) {
}
}
function isAudiobookCard(book: BookCard): boolean {
return book.files.some((file) => (file.format ? isAudioFormat(file.format) : false))
}
function coverWidthClass(book: BookCard): string {
return isAudiobookCard(book) ? SQUARE_AUDIOBOOK_COVER_WIDTH_CLASS : DEFAULT_COVER_WIDTH_CLASS
}
function coverAspectRatio(book: BookCard): CoverAspectRatio {
return isAudiobookCard(book) ? '1/1' : '2/3'
return book.coverAspectRatio === '1/1' ? SQUARE_COVER_WIDTH_CLASS : PORTRAIT_COVER_WIDTH_CLASS
}
</script>
@@ -168,7 +160,7 @@ function coverAspectRatio(book: BookCard): CoverAspectRatio {
style="animation: dashboardFadeUp 0.35s ease both"
:style="{ animationDelay: `${index * 35}ms` }"
>
<BookCoverCard :book="book" :cover-aspect-ratio="coverAspectRatio(book)" @action="handleBookAction(book, $event)" />
<BookCoverCard :book="book" :cover-aspect-ratio="book.coverAspectRatio" @action="handleBookAction(book, $event)" />
</div>
</div>
</section>
@@ -6,6 +6,7 @@ function makeBook(format: string | null | undefined, overrides: Partial<BookCard
return {
id: 1,
status: 'present',
coverAspectRatio: '2/3',
title: 'Series Book',
authors: [],
seriesId: 42,
@@ -1,6 +1,4 @@
import { isAudioFormat, type BookCard } from '@bookorbit/types'
const COMIC_FORMATS = new Set(['cbz', 'cbr', 'cb7'])
import { getBookMediaProfile, type BookCard } from '@bookorbit/types'
export const SERIES_BOOK_MEDIA_GROUP_DEFS = [
{ key: 'books', label: 'Books' },
@@ -17,12 +15,9 @@ export type SeriesBookMediaGroup = {
}
export function getSeriesBookMediaGroupKey(book: BookCard): SeriesBookMediaGroupKey {
const file = book.files.find((candidate) => candidate.role === 'primary') ?? book.files.find((candidate) => candidate.format) ?? null
const format = file?.format?.trim().toLowerCase()
if (!format) return 'books'
if (isAudioFormat(format)) return 'audiobooks'
if (COMIC_FORMATS.has(format)) return 'comics'
const primaryMediaKind = getBookMediaProfile(book.files).primaryMediaKind
if (primaryMediaKind === 'audiobook') return 'audiobooks'
if (primaryMediaKind === 'comic') return 'comics'
return 'books'
}
@@ -107,6 +107,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
status: 'present',
coverAspectRatio: '2/3',
title: 'Series Book',
authors: ['Author'],
seriesId: 42,
@@ -123,7 +123,7 @@ const hiddenLeadGenres = computed(() => {
const totalGenres = leadBook.value?.genres.length ?? 0
return Math.max(0, totalGenres - displayedLeadGenres.value.length)
})
const SERIES_AUDIOBOOK_COVER_SCALE = 1.25
const SERIES_SQUARE_COVER_SCALE = 1.25
const GROUP_BY_MEDIA_STORAGE_KEY = 'bookorbit:series-detail:group-by-media'
const seriesBooksCoverSize = computed(() => Math.max(125, portraitCoverSize.value - 20))
const leadMetaItems = computed(() => {
@@ -249,7 +249,7 @@ const leadCoverDisplayModes = computed(() => visibleLeadCoverBookIds.value.map((
const scaledLeadCoverStyles = computed(() =>
leadCoverStyles.value.map((base, index) => {
const ratio = leadCoverRatioAt(index)
const squareScale = resolveSquareCoverScale(ratio, SERIES_AUDIOBOOK_COVER_SCALE)
const squareScale = resolveSquareCoverScale(ratio, SERIES_SQUARE_COVER_SCALE)
const squareTransform = centeredBottomScaleTransform(squareScale)
const baseForRatio = {
...base,
@@ -678,7 +678,7 @@ defineOptions({ name: 'SeriesDetailView' })
:books="group.books"
:cover-size="seriesBooksCoverSize"
:grid-gap="gridGap"
:audio-cover-scale="SERIES_AUDIOBOOK_COVER_SCALE"
:square-cover-scale="SERIES_SQUARE_COVER_SCALE"
:virtualized="false"
@action="handleBookAction"
@update:book="handleBookUpdate"
@@ -691,7 +691,7 @@ defineOptions({ name: 'SeriesDetailView' })
:books="books"
:cover-size="seriesBooksCoverSize"
:grid-gap="gridGap"
:audio-cover-scale="SERIES_AUDIOBOOK_COVER_SCALE"
:square-cover-scale="SERIES_SQUARE_COVER_SCALE"
:virtualized="false"
@action="handleBookAction"
@update:book="handleBookUpdate"
+37
View File
@@ -4,6 +4,7 @@ import type { AudiobookChapter, NarratorRef } from "./audiobook";
import type { ComicMetadataFields } from "./metadata-fetch";
import type { BookFileWriteField, WriteResult } from "./file-write";
import type { CustomMetadataBookValue } from "./custom-metadata";
import type { CoverAspectRatio } from "./library";
export const BOOK_FORMATS = ["epub", "pdf", "mobi", "azw3", "cbz", "cbr", "cb7", "fb2", "m4b", "mp3", "m4a", "opus", "ogg", "flac"] as const;
export type BookFormat = (typeof BOOK_FORMATS)[number];
@@ -75,6 +76,39 @@ export type BookFileRef = {
sizeBytes: number | null;
};
export type BookMediaKind = "ebook" | "audiobook" | "comic" | "unknown";
export type BookMediaProfile = {
primaryMediaKind: BookMediaKind;
hasEbook: boolean;
hasAudio: boolean;
hasComic: boolean;
};
type BookMediaFile = Pick<BookFileRef, "format" | "role">;
export function getPrimaryBookFile<T extends BookMediaFile>(files: readonly T[]): T | null {
return files.find((file) => file.role === "primary") ?? files.find((file) => file.format != null) ?? files[0] ?? null;
}
export function getBookMediaKind(format: string | null | undefined): BookMediaKind {
const normalized = format?.trim().toLowerCase();
if (!normalized) return "unknown";
if (isAudioFormat(normalized)) return "audiobook";
if (isComicFormat(normalized)) return "comic";
return "ebook";
}
export function getBookMediaProfile(files: readonly BookMediaFile[]): BookMediaProfile {
const mediaKinds = files.map((file) => getBookMediaKind(file.format));
return {
primaryMediaKind: getBookMediaKind(getPrimaryBookFile(files)?.format),
hasEbook: mediaKinds.includes("ebook"),
hasAudio: mediaKinds.includes("audiobook"),
hasComic: mediaKinds.includes("comic"),
};
}
export type BookSeriesMembership = {
seriesId: number;
seriesName: string;
@@ -85,6 +119,7 @@ export type BookSeriesMembership = {
export type BookCard = {
id: number;
status: string;
coverAspectRatio: CoverAspectRatio;
title: string | null;
authors: string[];
seriesId?: number | null;
@@ -282,6 +317,7 @@ export type BooksPage = {
export type BookRecommendation = {
id: number;
title: string | null;
coverAspectRatio: CoverAspectRatio;
updatedAt: string | null;
hasCover: boolean;
authors: string[];
@@ -292,6 +328,7 @@ export type BookRecommendation = {
export type SeriesBookRecommendation = {
id: number;
title: string | null;
coverAspectRatio: CoverAspectRatio;
updatedAt: string | null;
seriesIndex: number | null;
hasCover: boolean;
+4
View File
@@ -1,6 +1,10 @@
export type OrganizationMode = "book_per_file" | "book_per_folder";
export type CoverAspectRatio = "2/3" | "1/1";
export function normalizeCoverAspectRatio(value: unknown): CoverAspectRatio {
return value === "1/1" ? "1/1" : "2/3";
}
export const DEFAULT_FORMAT_PRIORITY = [
"epub",
"kepub",
+36 -11
View File
@@ -301,8 +301,8 @@ describe('BookRepository', () => {
it('maps hasCover from coverSource and aggregates authors per book in recommendation rows', async () => {
const bookRows = [
{ id: 10, title: 'Dune', coverSource: 'extracted', primaryFormat: 'm4b' },
{ id: 11, title: 'Foundation', coverSource: null, primaryFormat: 'epub' },
{ id: 10, title: 'Dune', coverAspectRatio: '2/3', coverSource: 'extracted', primaryFormat: 'm4b' },
{ id: 11, title: 'Foundation', coverAspectRatio: '1/1', coverSource: null, primaryFormat: 'epub' },
];
const authorRows = [
{ bookId: 10, name: 'Frank Herbert' },
@@ -317,10 +317,20 @@ describe('BookRepository', () => {
const result = await repo.findRecommendationTitlesByBookIds([10, 11]);
expect(result).toEqual([
{ id: 10, title: 'Dune', updatedAt: null, hasCover: true, authors: ['Frank Herbert'], isAudiobook: true, isComic: false },
{
id: 10,
title: 'Dune',
coverAspectRatio: '2/3',
updatedAt: null,
hasCover: true,
authors: ['Frank Herbert'],
isAudiobook: true,
isComic: false,
},
{
id: 11,
title: 'Foundation',
coverAspectRatio: '1/1',
updatedAt: null,
hasCover: false,
authors: ['Isaac Asimov', 'Robert Heinlein'],
@@ -331,7 +341,7 @@ describe('BookRepository', () => {
});
it('returns hasCover false when coverSource is null in recommendation rows', async () => {
const bookRows = [{ id: 5, title: 'No Cover', coverSource: null, primaryFormat: null }];
const bookRows = [{ id: 5, title: 'No Cover', coverAspectRatio: '2/3', coverSource: null, primaryFormat: null }];
const db = {
select: vi.fn().mockReturnValueOnce(makeSelectChain('where', bookRows)).mockReturnValueOnce(makeSelectChain('where', [])),
};
@@ -339,11 +349,13 @@ describe('BookRepository', () => {
const result = await repo.findRecommendationTitlesByBookIds([5]);
expect(result).toEqual([{ id: 5, title: 'No Cover', updatedAt: null, hasCover: false, authors: [], isAudiobook: false, isComic: false }]);
expect(result).toEqual([
{ id: 5, title: 'No Cover', coverAspectRatio: '2/3', updatedAt: null, hasCover: false, authors: [], isAudiobook: false, isComic: false },
]);
});
it('treats primary format checks as case-insensitive in recommendation rows', async () => {
const bookRows = [{ id: 6, title: 'Audio Case', coverSource: 'custom', primaryFormat: 'MP3' }];
const bookRows = [{ id: 6, title: 'Audio Case', coverAspectRatio: '2/3', coverSource: 'custom', primaryFormat: 'MP3' }];
const db = {
select: vi.fn().mockReturnValueOnce(makeSelectChain('where', bookRows)).mockReturnValueOnce(makeSelectChain('where', [])),
};
@@ -351,11 +363,13 @@ describe('BookRepository', () => {
const result = await repo.findRecommendationTitlesByBookIds([6]);
expect(result).toEqual([{ id: 6, title: 'Audio Case', updatedAt: null, hasCover: true, authors: [], isAudiobook: true, isComic: false }]);
expect(result).toEqual([
{ id: 6, title: 'Audio Case', coverAspectRatio: '2/3', updatedAt: null, hasCover: true, authors: [], isAudiobook: true, isComic: false },
]);
});
it('flags comic primary formats as isComic in recommendation rows', async () => {
const bookRows = [{ id: 8, title: 'Comic Case', coverSource: 'custom', primaryFormat: 'CBR' }];
const bookRows = [{ id: 8, title: 'Comic Case', coverAspectRatio: '1/1', coverSource: 'custom', primaryFormat: 'CBR' }];
const db = {
select: vi.fn().mockReturnValueOnce(makeSelectChain('where', bookRows)).mockReturnValueOnce(makeSelectChain('where', [])),
};
@@ -363,12 +377,14 @@ describe('BookRepository', () => {
const result = await repo.findRecommendationTitlesByBookIds([8]);
expect(result).toEqual([{ id: 8, title: 'Comic Case', updatedAt: null, hasCover: true, authors: [], isAudiobook: false, isComic: true }]);
expect(result).toEqual([
{ id: 8, title: 'Comic Case', coverAspectRatio: '1/1', updatedAt: null, hasCover: true, authors: [], isAudiobook: false, isComic: true },
]);
});
it('maps id-list helper rows and primary-file lookups', async () => {
const libraryRows = [{ id: 1, libraryId: 7 }];
const recommendationBookRows = [{ id: 1, title: 'Dune', coverSource: 'extracted', primaryFormat: 'm4b' }];
const recommendationBookRows = [{ id: 1, title: 'Dune', coverAspectRatio: '2/3', coverSource: 'extracted', primaryFormat: 'm4b' }];
const recommendationAuthorRows = [{ bookId: 1, name: 'Frank Herbert' }];
const allIdsRows = [{ id: 3 }, { id: 4 }];
const primaryFileRows = [{ absolutePath: '/books/a.epub', format: 'epub' }];
@@ -394,7 +410,16 @@ describe('BookRepository', () => {
await expect(repo.findLibraryIdsByBookIds([1])).resolves.toEqual(libraryRows);
await expect(repo.findRecommendationTitlesByBookIds([1])).resolves.toEqual([
{ id: 1, title: 'Dune', updatedAt: null, hasCover: true, authors: ['Frank Herbert'], isAudiobook: true, isComic: false },
{
id: 1,
title: 'Dune',
coverAspectRatio: '2/3',
updatedAt: null,
hasCover: true,
authors: ['Frank Herbert'],
isAudiobook: true,
isComic: false,
},
]);
await expect(repo.findPrimaryFilesByBookIds([1])).resolves.toEqual(primaryFilesByIds);
await expect(repo.findAllFilesByBookIds([1])).resolves.toEqual(allFilesByIds);
+14 -6
View File
@@ -12,7 +12,8 @@ import type {
TemporalJumpBucketPrecision,
TemporalJumpBucketUnit,
} from '@bookorbit/types';
import { isAudioFormat, isComicFormat } from '@bookorbit/types';
import type { BookRecommendation } from '@bookorbit/types';
import { isAudioFormat, isComicFormat, normalizeCoverAspectRatio } from '@bookorbit/types';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
import { SeriesIdentityService } from '../../common/services/series-identity.service';
@@ -59,6 +60,7 @@ type JsonObj = Record<string, unknown>;
type CollapsedRawRow = {
id: number;
status: string;
cover_aspect_ratio: string;
primary_file_id: number | null;
folder_path: string;
added_at: string;
@@ -535,6 +537,7 @@ export class BookRepository {
.select({
id: books.id,
status: books.status,
coverAspectRatio: libraries.coverAspectRatio,
primaryFileId: books.primaryFileId,
folderPath: books.folderPath,
addedAt: books.addedAt,
@@ -559,6 +562,7 @@ export class BookRepository {
_total: sql<number>`count(*) over()`.as('_total'),
})
.from(books)
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(userBookRatings, and(eq(userBookRatings.bookId, books.id), eq(userBookRatings.userId, userId)))
.where(visibleWhere)
@@ -726,6 +730,7 @@ export class BookRepository {
rows: Array<{
id: number;
status: string;
coverAspectRatio: string;
primaryFileId: number | null;
folderPath: string;
addedAt: Date;
@@ -783,6 +788,7 @@ export class BookRepository {
books.id,
books.library_id,
books.status,
libraries.cover_aspect_ratio,
books.primary_file_id,
books.primary_author_sort_name,
books.folder_path,
@@ -806,6 +812,7 @@ export class BookRepository {
book_metadata.metadata_score,
NULLIF(lower(btrim(book_metadata.series_name)), '') AS norm_series
FROM books
INNER JOIN libraries ON libraries.id = books.library_id
LEFT JOIN book_metadata ON book_metadata.book_id = books.id
WHERE ${whereFragment}
),
@@ -915,6 +922,7 @@ export class BookRepository {
SELECT DISTINCT ON (${sql.raw(COLLAPSE_GROUP_KEY_SQL)})
base.id,
base.status,
base.cover_aspect_ratio,
base.primary_file_id,
base.folder_path,
base.added_at,
@@ -981,6 +989,7 @@ export class BookRepository {
const mappedRows = rawRows.map((r) => ({
id: r.id,
status: r.status,
coverAspectRatio: r.cover_aspect_ratio,
primaryFileId: r.primary_file_id,
folderPath: r.folder_path,
addedAt: new Date(r.added_at),
@@ -1631,11 +1640,7 @@ 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; updatedAt: string | null; hasCover: boolean; authors: string[]; isAudiobook: boolean; isComic: boolean }[]
> {
async findRecommendationTitlesByBookIds(bookIds: number[]): Promise<BookRecommendation[]> {
if (bookIds.length === 0) return [];
const [rows, authorRows] = await Promise.all([
@@ -1643,11 +1648,13 @@ export class BookRepository {
.select({
id: books.id,
title: bookMetadata.title,
coverAspectRatio: libraries.coverAspectRatio,
updatedAt: books.updatedAt,
coverSource: bookMetadata.coverSource,
primaryFormat: bookFiles.format,
})
.from(books)
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.where(inArray(books.id, bookIds)),
@@ -1668,6 +1675,7 @@ export class BookRepository {
return rows.map((r) => ({
id: r.id,
title: r.title,
coverAspectRatio: normalizeCoverAspectRatio(r.coverAspectRatio),
updatedAt: r.updatedAt?.toISOString() ?? null,
hasCover: r.coverSource !== null,
authors: authorsByBook.get(r.id) ?? [],
@@ -4,6 +4,7 @@ function makeBookRow(id: number, overrides?: Partial<Parameters<typeof assembleB
return {
id,
status: 'ready',
coverAspectRatio: '2/3',
folderPath: `/books/folder-${id}`,
addedAt: new Date('2024-01-01T00:00:00.000Z'),
title: `Book ${id}`,
@@ -37,6 +38,24 @@ describe('assembleBookCards', () => {
expect(card.title).toBe('Book 1');
expect(card.authors).toEqual(['Author A']);
expect(card.files).toEqual([{ id: 10, format: 'epub', role: 'primary', sizeBytes: null }]);
expect(card.coverAspectRatio).toBe('2/3');
});
it('preserves square library cover slots independently of file format', () => {
const rows = [makeBookRow(1, { coverAspectRatio: '1/1' })];
const fileRows = [{ bookId: 1, id: 10, format: 'epub', role: 'primary', sizeBytes: null }];
const [card] = assembleBookCards(rows, [], fileRows, [], []);
expect(card.coverAspectRatio).toBe('1/1');
});
it('normalizes an invalid stored cover ratio to portrait', () => {
const rows = [makeBookRow(1, { coverAspectRatio: 'invalid' })];
const [card] = assembleBookCards(rows, [], [], [], []);
expect(card.coverAspectRatio).toBe('2/3');
});
it('includes updatedAt, metadataScore, and file size when present', () => {
@@ -318,6 +337,7 @@ function makeCollapsedRow(id: number, overrides?: Record<string, unknown>) {
return {
id,
status: 'ready',
coverAspectRatio: '2/3',
primaryFileId: null,
folderPath: `/books/folder-${id}`,
addedAt: new Date('2024-01-01T00:00:00.000Z'),
@@ -348,6 +368,7 @@ function makeBookCard(id: number, overrides?: Record<string, unknown>) {
return {
id,
status: 'ready',
coverAspectRatio: '2/3' as const,
title: `Book ${id}`,
authors: [] as string[],
seriesName: null as string | null,
@@ -1,7 +1,7 @@
import { basename } from 'path';
import type { BookCard, BookMetadataLockField, CollapsedSeriesInfo, CustomMetadataBookValue, UserBookStatus } from '@bookorbit/types';
import { BOOK_METADATA_LOCK_FIELDS } from '@bookorbit/types';
import { BOOK_METADATA_LOCK_FIELDS, normalizeCoverAspectRatio } from '@bookorbit/types';
const LOCK_FIELD_SET = new Set<string>(BOOK_METADATA_LOCK_FIELDS);
@@ -13,6 +13,7 @@ function normalizeLockedFields(raw: string[] | null | undefined): BookMetadataLo
type BookRow = {
id: number;
status: string;
coverAspectRatio: string;
primaryFileId?: number | null;
folderPath: string;
addedAt: Date;
@@ -181,6 +182,7 @@ export function assembleBookCards(
return {
id: row.id,
status: row.status,
coverAspectRatio: normalizeCoverAspectRatio(row.coverAspectRatio),
title: row.title ?? basename(row.folderPath),
seriesId: row.seriesId ?? null,
seriesName: row.seriesName ?? null,
@@ -233,8 +233,8 @@ describe('RecommendationRepository', () => {
it('queries series books with expected shape when input is valid', async () => {
const rows = [
{ bookId: 1, title: 'Book 1', seriesIndex: 1, coverSource: 'extracted', primaryFormat: 'm4b' },
{ bookId: 2, title: 'Book 2', seriesIndex: 2, coverSource: null, primaryFormat: 'epub' },
{ bookId: 1, title: 'Book 1', coverAspectRatio: '2/3', seriesIndex: 1, coverSource: 'extracted', primaryFormat: 'm4b' },
{ bookId: 2, title: 'Book 2', coverAspectRatio: '1/1', seriesIndex: 2, coverSource: null, primaryFormat: 'epub' },
];
const authorRows = [{ bookId: 1, name: 'Frank Herbert' }];
const { db, select, chains } = makeDb([
@@ -249,6 +249,7 @@ describe('RecommendationRepository', () => {
{
bookId: 1,
title: 'Book 1',
coverAspectRatio: '2/3',
updatedAt: null,
seriesIndex: 1,
coverSource: 'extracted',
@@ -256,10 +257,21 @@ describe('RecommendationRepository', () => {
isAudiobook: true,
isComic: false,
},
{ bookId: 2, title: 'Book 2', updatedAt: null, seriesIndex: 2, coverSource: null, authorNames: [], isAudiobook: false, isComic: false },
{
bookId: 2,
title: 'Book 2',
coverAspectRatio: '1/1',
updatedAt: null,
seriesIndex: 2,
coverSource: null,
authorNames: [],
isAudiobook: false,
isComic: false,
},
]);
expect(select).toHaveBeenCalledTimes(2);
expect(chains[0].from).toHaveBeenCalledTimes(1);
expect(chains[0].innerJoin).toHaveBeenCalledTimes(1);
expect(chains[0].leftJoin).toHaveBeenCalledTimes(2);
expect(chains[0].where).toHaveBeenCalledTimes(1);
expect(chains[0].orderBy).toHaveBeenCalledTimes(1);
@@ -267,7 +279,7 @@ describe('RecommendationRepository', () => {
});
it('returns series books with empty authorNames when no authors exist', async () => {
const rows = [{ bookId: 5, title: 'Solo Book', seriesIndex: null, coverSource: null, primaryFormat: null }];
const rows = [{ bookId: 5, title: 'Solo Book', coverAspectRatio: '2/3', seriesIndex: null, coverSource: null, primaryFormat: null }];
const { db } = makeDb([
{ terminal: 'limit', result: rows },
{ terminal: 'where', result: [] },
@@ -277,7 +289,17 @@ describe('RecommendationRepository', () => {
const result = await repo.findSeriesBooks(88, [1]);
expect(result).toEqual([
{ bookId: 5, title: 'Solo Book', updatedAt: null, seriesIndex: null, coverSource: null, authorNames: [], isAudiobook: false, isComic: false },
{
bookId: 5,
title: 'Solo Book',
coverAspectRatio: '2/3',
updatedAt: null,
seriesIndex: null,
coverSource: null,
authorNames: [],
isAudiobook: false,
isComic: false,
},
]);
});
@@ -292,7 +314,7 @@ describe('RecommendationRepository', () => {
});
it('queries author books with expected shape when input is valid', async () => {
const rows = [{ bookId: 10, title: 'Other Book', sharedAuthors: 2, coverSource: 'extracted', primaryFormat: 'MP3' }];
const rows = [{ bookId: 10, title: 'Other Book', coverAspectRatio: '1/1', sharedAuthors: 2, coverSource: 'extracted', primaryFormat: 'MP3' }];
const authorRows = [{ bookId: 10, name: 'Terry Pratchett' }];
const { db, select, chains } = makeDb([
{ terminal: 'where', result: undefined },
@@ -307,6 +329,7 @@ describe('RecommendationRepository', () => {
{
bookId: 10,
title: 'Other Book',
coverAspectRatio: '1/1',
updatedAt: null,
coverSource: 'extracted',
authorNames: ['Terry Pratchett'],
@@ -315,13 +338,13 @@ describe('RecommendationRepository', () => {
},
]);
expect(select).toHaveBeenCalledTimes(3);
expect(chains[1].innerJoin).toHaveBeenCalledTimes(1);
expect(chains[1].innerJoin).toHaveBeenCalledTimes(2);
expect(chains[1].leftJoin).toHaveBeenCalledTimes(2);
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, primaryFormat: null }];
const rows = [{ bookId: 7, title: 'Anonymous Work', coverAspectRatio: '2/3', sharedAuthors: 1, coverSource: null, primaryFormat: null }];
const { db } = makeDb([
{ terminal: 'where', result: undefined },
{ terminal: 'limit', result: rows },
@@ -332,12 +355,21 @@ describe('RecommendationRepository', () => {
const result = await repo.findAuthorBooks(1, [1]);
expect(result).toEqual([
{ bookId: 7, title: 'Anonymous Work', updatedAt: null, coverSource: null, authorNames: [], isAudiobook: false, isComic: false },
{
bookId: 7,
title: 'Anonymous Work',
coverAspectRatio: '2/3',
updatedAt: null,
coverSource: null,
authorNames: [],
isAudiobook: false,
isComic: false,
},
]);
});
it('flags comic primary formats as isComic for series books', async () => {
const rows = [{ bookId: 9, title: 'Comic Issue', seriesIndex: 3, coverSource: 'extracted', primaryFormat: 'CBZ' }];
const rows = [{ bookId: 9, title: 'Comic Issue', coverAspectRatio: '1/1', seriesIndex: 3, coverSource: 'extracted', primaryFormat: 'CBZ' }];
const { db } = makeDb([
{ terminal: 'limit', result: rows },
{ terminal: 'where', result: [] },
@@ -350,6 +382,7 @@ describe('RecommendationRepository', () => {
{
bookId: 9,
title: 'Comic Issue',
coverAspectRatio: '1/1',
updatedAt: null,
seriesIndex: 3,
coverSource: 'extracted',
@@ -6,7 +6,7 @@ import type { ContentFilterRules } from '@bookorbit/types';
import { isAudioFormat, isComicFormat } from '@bookorbit/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookGenres, bookMetadata, bookTags, books, genres, tags } from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookGenres, bookMetadata, bookTags, books, genres, libraries, tags } from '../../db/schema';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
type Db = NodePgDatabase<typeof schema>;
@@ -17,6 +17,7 @@ const AUTHOR_BOOKS_LIMIT = 25;
export interface SeriesBookRow {
bookId: number;
title: string | null;
coverAspectRatio: string;
updatedAt: Date | null;
seriesIndex: number | null;
coverSource: string | null;
@@ -28,6 +29,7 @@ export interface SeriesBookRow {
export interface AuthorBookRow {
bookId: number;
title: string | null;
coverAspectRatio: string;
updatedAt: Date | null;
coverSource: string | null;
authorNames: string[];
@@ -170,12 +172,14 @@ export class RecommendationRepository {
.select({
bookId: books.id,
title: bookMetadata.title,
coverAspectRatio: libraries.coverAspectRatio,
updatedAt: books.updatedAt,
seriesIndex: bookMetadata.seriesIndex,
coverSource: bookMetadata.coverSource,
primaryFormat: bookFiles.format,
})
.from(books)
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.where(and(inArray(books.libraryId, libraryIds), eq(bookMetadata.seriesId, seriesId), ...filterClauses))
@@ -197,6 +201,7 @@ export class RecommendationRepository {
return rows.map((r) => ({
bookId: r.bookId,
title: r.title,
coverAspectRatio: r.coverAspectRatio,
updatedAt: r.updatedAt ?? null,
seriesIndex: r.seriesIndex,
coverSource: r.coverSource,
@@ -216,6 +221,7 @@ export class RecommendationRepository {
.select({
bookId: books.id,
title: bookMetadata.title,
coverAspectRatio: libraries.coverAspectRatio,
updatedAt: books.updatedAt,
coverSource: bookMetadata.coverSource,
sharedAuthors: sql<number>`count(*)::int`.as('shared_authors'),
@@ -223,10 +229,11 @@ export class RecommendationRepository {
})
.from(bookAuthors)
.innerJoin(books, eq(books.id, bookAuthors.bookId))
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.where(and(inArray(bookAuthors.authorId, authorIds), inArray(books.libraryId, libraryIds), ne(books.id, bookId), ...filterClauses))
.groupBy(books.id, books.updatedAt, bookMetadata.title, bookMetadata.coverSource, bookFiles.format)
.groupBy(books.id, books.updatedAt, libraries.coverAspectRatio, bookMetadata.title, bookMetadata.coverSource, bookFiles.format)
.orderBy(desc(sql`shared_authors`), asc(bookMetadata.title), asc(books.id))
.limit(AUTHOR_BOOKS_LIMIT);
@@ -245,6 +252,7 @@ export class RecommendationRepository {
return rows.map((r) => ({
bookId: r.bookId,
title: r.title,
coverAspectRatio: r.coverAspectRatio,
updatedAt: r.updatedAt ?? null,
coverSource: r.coverSource,
authorNames: authorsByBook.get(r.bookId) ?? [],
@@ -84,11 +84,11 @@ describe('RecommendationService', () => {
recRepo.findAnnCandidates.mockResolvedValue([{ bookId: 91, cosineSim: 0.78, seriesId: null, seriesName: null, rating: null }]);
recRepo.getCandidateMetadata.mockResolvedValue([{ bookId: 91, authorNames: [], genreTagNames: [] }]);
bookRepo.findRecommendationTitlesByBookIds.mockResolvedValue([
{ id: 91, title: 'Fallback Match', updatedAt: null, hasCover: false, authors: [], isAudiobook: false },
{ id: 91, title: 'Fallback Match', coverAspectRatio: '1/1', updatedAt: null, hasCover: false, authors: [], isAudiobook: false },
]);
await expect(service.getRecommendations(55, makeUser())).resolves.toEqual([
{ id: 91, title: 'Fallback Match', updatedAt: null, hasCover: false, authors: [], isAudiobook: false },
{ id: 91, title: 'Fallback Match', coverAspectRatio: '1/1', updatedAt: null, hasCover: false, authors: [], isAudiobook: false },
]);
expect(embedder.embedBook).toHaveBeenCalledWith(55);
expect(recRepo.findAnnCandidates).toHaveBeenCalledWith([0.4, 0.6], 55, [7, 9], EMPTY_CONTENT_FILTER_RULES);
@@ -144,15 +144,31 @@ describe('RecommendationService', () => {
{ bookId: 200, authorNames: [], genreTagNames: [] },
]);
bookRepo.findRecommendationTitlesByBookIds.mockResolvedValue([
{ id: 200, title: 'Second', updatedAt: null, hasCover: true, authors: [], isAudiobook: false },
{ id: 100, title: 'First', updatedAt: null, hasCover: false, authors: ['Frank Herbert'], isAudiobook: false },
{ id: 200, title: 'Second', coverAspectRatio: '2/3', updatedAt: null, hasCover: true, authors: [], isAudiobook: false },
{ id: 100, title: 'First', coverAspectRatio: '1/1', updatedAt: null, hasCover: false, authors: ['Frank Herbert'], isAudiobook: false },
]);
const result = await service.getRecommendations(9, makeUser());
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ id: 100, title: 'First', updatedAt: null, hasCover: false, authors: ['Frank Herbert'], isAudiobook: false });
expect(result[1]).toEqual({ id: 200, title: 'Second', updatedAt: null, hasCover: true, authors: [], isAudiobook: false });
expect(result[0]).toEqual({
id: 100,
title: 'First',
coverAspectRatio: '1/1',
updatedAt: null,
hasCover: false,
authors: ['Frank Herbert'],
isAudiobook: false,
});
expect(result[1]).toEqual({
id: 200,
title: 'Second',
coverAspectRatio: '2/3',
updatedAt: null,
hasCover: true,
authors: [],
isAudiobook: false,
});
});
it('normalizes author and genre-tag metadata before similarity scoring', async () => {
@@ -176,15 +192,31 @@ describe('RecommendationService', () => {
{ bookId: 2, authorNames: [], genreTagNames: [] },
]);
bookRepo.findRecommendationTitlesByBookIds.mockResolvedValue([
{ id: 1, title: 'Token Match', updatedAt: null, hasCover: true, authors: ['frank herbert'], isAudiobook: true },
{ id: 2, title: 'Cosine Only', updatedAt: null, hasCover: false, authors: [], isAudiobook: false },
{ id: 1, title: 'Token Match', coverAspectRatio: '2/3', updatedAt: null, hasCover: true, authors: ['frank herbert'], isAudiobook: true },
{ id: 2, title: 'Cosine Only', coverAspectRatio: '1/1', updatedAt: null, hasCover: false, authors: [], isAudiobook: false },
]);
const result = await service.getRecommendations(13, makeUser());
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ id: 1, title: 'Token Match', updatedAt: null, hasCover: true, authors: ['frank herbert'], isAudiobook: true });
expect(result[1]).toEqual({ id: 2, title: 'Cosine Only', updatedAt: null, hasCover: false, authors: [], isAudiobook: false });
expect(result[0]).toEqual({
id: 1,
title: 'Token Match',
coverAspectRatio: '2/3',
updatedAt: null,
hasCover: true,
authors: ['frank herbert'],
isAudiobook: true,
});
expect(result[1]).toEqual({
id: 2,
title: 'Cosine Only',
coverAspectRatio: '1/1',
updatedAt: null,
hasCover: false,
authors: [],
isAudiobook: false,
});
});
it('filters out ANN results that cannot be mapped to cards', async () => {
@@ -208,13 +240,21 @@ describe('RecommendationService', () => {
{ bookId: 11, authorNames: [], genreTagNames: [] },
]);
bookRepo.findRecommendationTitlesByBookIds.mockResolvedValue([
{ id: 11, title: 'Only Card', updatedAt: null, hasCover: true, authors: [], isAudiobook: false },
{ id: 11, title: 'Only Card', coverAspectRatio: '2/3', updatedAt: null, hasCover: true, authors: [], isAudiobook: false },
]);
const result = await service.getRecommendations(2, makeUser());
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ id: 11, title: 'Only Card', updatedAt: null, hasCover: true, authors: [], isAudiobook: false });
expect(result[0]).toEqual({
id: 11,
title: 'Only Card',
coverAspectRatio: '2/3',
updatedAt: null,
hasCover: true,
authors: [],
isAudiobook: false,
});
});
it('returns empty recommendations when user has no accessible libraries with ANN candidates', async () => {
@@ -264,6 +304,7 @@ describe('RecommendationService', () => {
Array.from({ length: 30 }, (_, i) => ({
id: i + 1,
title: `Book ${i + 1}`,
coverAspectRatio: '2/3',
updatedAt: null,
hasCover: false,
authors: [],
@@ -313,16 +354,27 @@ describe('RecommendationService', () => {
{
bookId: 1,
title: 'The Way of Kings',
coverAspectRatio: '2/3',
updatedAt: null,
seriesIndex: 1,
coverSource: 'extracted',
authorNames: ['Brandon Sanderson'],
isAudiobook: false,
},
{ bookId: 2, title: 'Words of Radiance', updatedAt: null, seriesIndex: 2, coverSource: null, authorNames: [], isAudiobook: false },
{
bookId: 2,
title: 'Words of Radiance',
coverAspectRatio: '1/1',
updatedAt: null,
seriesIndex: 2,
coverSource: null,
authorNames: [],
isAudiobook: false,
},
{
bookId: 3,
title: 'Oathbringer',
coverAspectRatio: '2/3',
updatedAt: null,
seriesIndex: 3,
coverSource: 'custom',
@@ -334,9 +386,36 @@ describe('RecommendationService', () => {
const result = await service.getSeriesBooks(2, makeUser());
expect(result).toEqual([
{ id: 1, title: 'The Way of Kings', updatedAt: null, seriesIndex: 1, hasCover: true, authors: ['Brandon Sanderson'], isAudiobook: false },
{ id: 2, title: 'Words of Radiance', updatedAt: null, seriesIndex: 2, hasCover: false, authors: [], isAudiobook: false },
{ id: 3, title: 'Oathbringer', updatedAt: null, seriesIndex: 3, hasCover: true, authors: ['Brandon Sanderson'], isAudiobook: false },
{
id: 1,
title: 'The Way of Kings',
coverAspectRatio: '2/3',
updatedAt: null,
seriesIndex: 1,
hasCover: true,
authors: ['Brandon Sanderson'],
isAudiobook: false,
},
{
id: 2,
title: 'Words of Radiance',
coverAspectRatio: '1/1',
updatedAt: null,
seriesIndex: 2,
hasCover: false,
authors: [],
isAudiobook: false,
},
{
id: 3,
title: 'Oathbringer',
coverAspectRatio: '2/3',
updatedAt: null,
seriesIndex: 3,
hasCover: true,
authors: ['Brandon Sanderson'],
isAudiobook: false,
},
]);
expect(recRepo.findSeriesBooks).toHaveBeenCalledWith(42, [5, 6], EMPTY_CONTENT_FILTER_RULES);
});
@@ -378,15 +457,23 @@ describe('RecommendationService', () => {
bookRepo.findLibraryIdByBookId.mockResolvedValue(5);
libraryService.findAccessibleLibraryIds.mockResolvedValue([5]);
recRepo.findAuthorBooks.mockResolvedValue([
{ bookId: 10, title: 'Other Book A', updatedAt: null, coverSource: 'extracted', authorNames: ['Jane Austen'], isAudiobook: false },
{ bookId: 20, title: 'Other Book B', updatedAt: null, coverSource: null, authorNames: [], isAudiobook: true },
{
bookId: 10,
title: 'Other Book A',
coverAspectRatio: '2/3',
updatedAt: null,
coverSource: 'extracted',
authorNames: ['Jane Austen'],
isAudiobook: false,
},
{ bookId: 20, title: 'Other Book B', coverAspectRatio: '1/1', updatedAt: null, coverSource: null, authorNames: [], isAudiobook: true },
]);
const result = await service.getAuthorBooks(1, makeUser());
expect(result).toEqual([
{ id: 10, title: 'Other Book A', updatedAt: null, hasCover: true, authors: ['Jane Austen'], isAudiobook: false },
{ id: 20, title: 'Other Book B', updatedAt: null, hasCover: false, authors: [], isAudiobook: true },
{ id: 10, title: 'Other Book A', coverAspectRatio: '2/3', updatedAt: null, hasCover: true, authors: ['Jane Austen'], isAudiobook: false },
{ id: 20, title: 'Other Book B', coverAspectRatio: '1/1', updatedAt: null, hasCover: false, authors: [], isAudiobook: true },
]);
expect(recRepo.findAuthorBooks).toHaveBeenCalledWith(1, [5], EMPTY_CONTENT_FILTER_RULES);
});
@@ -1,6 +1,7 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { BookRecommendation, SeriesBookRecommendation } from '@bookorbit/types';
import { normalizeCoverAspectRatio } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { BookEmbedderService } from '../embedding/book-embedder.service';
import { BookReadService } from '../book/book-read.service';
@@ -92,19 +93,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;
updatedAt: string | null;
hasCover: boolean;
authors: string[];
isAudiobook: boolean;
isComic: boolean;
} => row != null,
);
.filter((row): row is BookRecommendation => 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`,
@@ -147,6 +136,7 @@ export class RecommendationService {
return rows.map((r) => ({
id: r.bookId,
title: r.title,
coverAspectRatio: normalizeCoverAspectRatio(r.coverAspectRatio),
updatedAt: r.updatedAt?.toISOString() ?? null,
seriesIndex: r.seriesIndex,
hasCover: r.coverSource !== null,
@@ -182,6 +172,7 @@ export class RecommendationService {
return rows.map((r) => ({
id: r.bookId,
title: r.title,
coverAspectRatio: normalizeCoverAspectRatio(r.coverAspectRatio),
updatedAt: r.updatedAt?.toISOString() ?? null,
hasCover: r.coverSource !== null,
authors: r.authorNames,
@@ -187,6 +187,35 @@ describe('ScannerRepository', () => {
await expect(repo.findMissingBooksByFolderPath('/books/B', 5)).resolves.toEqual([{ id: 12, folderPath: '/books/B', status: 'missing' }]);
});
it('includes the owning library cover ratio in scanner book-card data', async () => {
const { repo, queues, db, chains } = makeRepo();
queues.select.push([
{
id: 1,
status: 'present',
coverAspectRatio: '1/1',
primaryFileId: 10,
folderPath: '/books/Audio',
addedAt: new Date('2026-01-01T00:00:00.000Z'),
title: 'Audio',
},
]);
queues.select.push([]);
queues.select.push([{ bookId: 1, id: 10, format: 'm4b', role: 'primary', sizeBytes: 123 }]);
queues.select.push([]);
const result = await repo.findBookCardData([1]);
expect(result.rows).toEqual([
expect.objectContaining({
id: 1,
coverAspectRatio: '1/1',
}),
]);
expect(db.select.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ coverAspectRatio: expect.anything() }));
expect(chains.select[0]?.mocks.innerJoin).toHaveBeenCalledTimes(1);
});
it('creates and updates book files and supports hash lookup', async () => {
const { repo, queues, chains, db } = makeRepo();
queues.select.push([{ id: 50, fileHash: 'abc123' }]);
@@ -533,6 +533,7 @@ export class ScannerRepository {
.select({
id: books.id,
status: books.status,
coverAspectRatio: libraries.coverAspectRatio,
primaryFileId: books.primaryFileId,
folderPath: books.folderPath,
addedAt: books.addedAt,
@@ -553,6 +554,7 @@ export class ScannerRepository {
hardcoverEditionId: bookMetadata.hardcoverEditionId,
})
.from(books)
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(inArray(books.id, bookIds)),
this.db