feat(cover): show each book's cover in the shape of its primary format

Pick the face slot from the primary file's medium instead of the library's
ratio, and send that frame per book on cards, detail, notebook labels and
recommendations. Grids keep uniform tiles, and the library setting becomes
Grid shape.
This commit is contained in:
neon
2026-10-08 06:19:00 +00:00
parent bd141802bd
commit 01b5638ea7
41 changed files with 419 additions and 140 deletions
@@ -1,17 +1,17 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { X } from '@lucide/vue'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import type { BookDetail, CoverMedium } from '@bookorbit/types'
import { useCoverVersions } from '../composables/useCoverVersions'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
import { useBookCoverAspectRatio } from '../composables/useBookCoverAspectRatio'
import { faceMedium } from '../lib/cover-slots'
import CoverMediumSwitch from './CoverMediumSwitch.vue'
const props = defineProps<{
open: boolean
book: Pick<BookDetail, 'id' | 'title' | 'covers' | 'coverVersion'>
book: Pick<BookDetail, 'id' | 'title' | 'covers' | 'coverVersion' | 'coverAspectRatio'>
/** The slot to open on. Without one the lightbox opens on the face, as the page shows it. */
medium?: CoverMedium | null
/** Unsaved editor images, shown in place of the saved slot they would replace. */
@@ -21,7 +21,7 @@ const emit = defineEmits<{ 'update:open': [open: boolean] }>()
const { t } = useI18n()
const { coverUrl } = useCoverVersions()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const coverAspectRatio = useBookCoverAspectRatio(() => props.book)
function hasImage(medium: CoverMedium): boolean {
return Boolean(props.previews?.[medium]) || props.book.covers[medium] !== null
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, ExternalLink, Eye, Folder, FolderPlus, Headphones, Pencil, Star, Trash2, X } from '@lucide/vue'
import { usePermissions } from '@/features/auth/composables/usePermissions'
@@ -15,7 +15,7 @@ import BookFormatChip from './BookFormatChip.vue'
import { bookFormatEntries, fileFormatKey } from '../lib/book-formats'
import { displayPublishedDate } from '../lib/published-date'
import { FORMAT_TO_GROUP, getPrimaryBookFile } from '@bookorbit/types'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
import { useBookCoverAspectRatio } from '../composables/useBookCoverAspectRatio'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useSafeHtml } from '@/features/book/composables/useSafeHtml'
@@ -77,7 +77,7 @@ const providerLinks = computed(() => (detail.value ? createBookProviderLinks(det
const safeDescription = useSafeHtml(() => detail.value?.description)
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const coverAspectRatio = useBookCoverAspectRatio(() => detail.value)
const { bookCoverDisplayMode } = useDisplaySettings()
const quickViewCoverAspectRatio = computed(() => {
if (
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getBookMediaProfile, type BookCard } from '@bookorbit/types'
import { getBookMediaProfile, type BookCard, type CoverAspectRatio } from '@bookorbit/types'
import { Library } from '@lucide/vue'
import BookCoverArtwork from './BookCoverArtwork.vue'
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
@@ -16,6 +16,8 @@ const STACK_COVER_STEP_PCT = 8
const props = defineProps<{
book: BookCard
/** A grid's own tile shape, which wins over the book's. */
coverAspectRatio?: CoverAspectRatio
showLabel?: boolean
selectionMode?: boolean
}>()
@@ -23,7 +25,7 @@ const props = defineProps<{
const route = useRoute()
const router = useRouter()
const injectedCoverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const coverAspectRatio = computed(() => props.book.coverAspectRatio ?? injectedCoverAspectRatio.value)
const effectiveCoverAspectRatio = computed(() => props.coverAspectRatio ?? props.book.coverAspectRatio ?? injectedCoverAspectRatio.value)
const { coverUrl } = useCoverVersions()
const { seriesCardCoverMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, cardOverlays } = useDisplaySettings()
@@ -31,7 +33,7 @@ const collapsed = computed(() => props.book.collapsedSeries!)
const seriesName = computed(() => props.book.seriesName ?? '')
const authorLine = computed(() => props.book.authors.join(', ') || null)
const authorQuery = computed(() => props.book.authors[0] ?? null)
const hoverTitleClampClass = computed(() => (coverAspectRatio.value === '1/1' ? 'line-clamp-1' : 'line-clamp-2'))
const hoverTitleClampClass = computed(() => (effectiveCoverAspectRatio.value === '1/1' ? 'line-clamp-1' : 'line-clamp-2'))
const showHoverText = computed(() => cardInfoMode.value === 'hover-overlay')
const overlayBackground = computed(() => (cardInfoMode.value === 'hover-overlay' ? 'bg-black/70' : 'bg-black/20'))
const showProgressBar = computed(() => cardOverlays.value.includes('progress-bar'))
@@ -172,7 +174,7 @@ const stackCoverStyles = computed(() =>
right: `${STACK_COVER_STEP_PCT * index}%`,
bottom: `${STACK_COVER_STEP_PCT * index}%`,
width: `${coverWidth}%`,
aspectRatio: coverAspectRatio.value,
aspectRatio: effectiveCoverAspectRatio.value,
zIndex: 100 - index,
boxShadow:
index === 0
@@ -211,7 +213,7 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
interactive
:disable-spine="isAudiobook"
:is-comic="isComic"
:style="{ aspectRatio: coverAspectRatio, ...(isStack ? { backgroundColor: 'transparent', boxShadow: 'none' } : {}) }"
:style="{ aspectRatio: effectiveCoverAspectRatio, ...(isStack ? { backgroundColor: 'transparent', boxShadow: 'none' } : {}) }"
>
<div v-if="isStack" class="absolute inset-0 isolate overflow-hidden" data-testid="series-cover-stack">
<div class="absolute inset-x-[12%] bottom-[5%] h-5 rounded-full bg-black/12 blur-2xl opacity-45" />
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import type { BookCard } from '@bookorbit/types'
import { COVER_ASPECT_RATIO_KEY } from '../lib/cover-aspect-ratio'
import VirtualBookGrid from './VirtualBookGrid.vue'
const scrollToItemMock = vi.fn<(index: number, options?: { align?: string }) => void>()
@@ -38,9 +39,9 @@ vi.mock('./BookCoverCard.vue', () => ({
vi.mock('./CollapsedSeriesCard.vue', () => ({
default: {
name: 'CollapsedSeriesCard',
props: ['book', 'showLabel', 'selectionMode'],
props: ['book', 'showLabel', 'selectionMode', 'coverAspectRatio'],
template:
'<div data-testid="collapsed-series-card" :data-selection-mode="String(selectionMode)">{{ book.id }}<span v-if="showLabel" data-testid="series-card-label-slot" /></div>',
'<div data-testid="collapsed-series-card" :data-selection-mode="String(selectionMode)" :data-cover-ratio="coverAspectRatio || \'\'">{{ book.id }}<span v-if="showLabel" data-testid="series-card-label-slot" /></div>',
},
}))
@@ -178,6 +179,24 @@ describe('VirtualBookGrid', () => {
expect(updatedBook?.title).toBe('Updated')
})
it("gives every virtualized tile the grid's shape, whatever the book's own", () => {
const series = makeBook(3, {
coverAspectRatio: '2/3',
collapsedSeries: { bookCount: 2, readCount: 0, coverBookIds: [3], seriesLatestAddedAt: null },
})
const wrapper = mount(VirtualBookGrid, {
props: {
books: [makeBook(1, { coverAspectRatio: '2/3' }), makeBook(2, { coverAspectRatio: '1/1' }), series],
coverSize: 120,
gridGap: 12,
},
global: { provide: { [COVER_ASPECT_RATIO_KEY as symbol]: ref('1/1') } },
})
expect(wrapper.findAll('[data-testid="book-card"]').map((card) => card.attributes('data-cover-ratio'))).toEqual(['1/1', '1/1'])
expect(wrapper.get('[data-testid="collapsed-series-card"]').attributes('data-cover-ratio')).toBe('1/1')
})
it.each([false, true])('forwards selection mode to collapsed series cards when virtualized=%s', (virtualized) => {
const seriesBook = makeBook(4, {
collapsedSeries: {
@@ -328,15 +328,18 @@ defineExpose({ scrollToIndex })
<template #default="{ item }">
<div class="book-grid-cell" :class="{ 'book-grid-cell--new': props.newBookIds.has(item.id) }">
<BookCoverSkeleton v-if="isBookPlaceholder(item)" />
<!-- Every tile takes the grid's shape; art of the other shape follows the cover display setting. -->
<CollapsedSeriesCard
v-else-if="asBook(item).collapsedSeries"
:book="asBook(item)"
:cover-aspect-ratio="coverAspectRatio"
:show-label="showLabel"
:selection-mode="selectionMode"
/>
<BookCoverCard
v-else
:book="asBook(item)"
:cover-aspect-ratio="coverAspectRatio"
:show-label="showLabel"
:selection-mode="selectionMode"
:selected="isSelected?.(item.id) ?? false"
@@ -16,7 +16,7 @@ vi.mock('../../composables/useCoverVersions', () => ({
}),
}))
type LightboxBook = Pick<BookDetail, 'id' | 'title' | 'covers' | 'coverVersion'>
type LightboxBook = Pick<BookDetail, 'id' | 'title' | 'covers' | 'coverVersion' | 'coverAspectRatio'>
function slot(updatedAt: string): BookCoverSlot {
return { source: 'extracted', updatedAt, width: null, height: null }
@@ -71,6 +71,13 @@ describe('BookCoverLightbox', () => {
expect(image().getAttribute('src')).toBe('/api/v1/books/3/cover?t=e1&medium=ebook')
})
it("opens on the book's own face rather than the surrounding view's shape", async () => {
mountLightbox({ book: { ...bothSlots, coverAspectRatio: '1/1' } }, '2/3')
await flushPromises()
expect(image().getAttribute('src')).toBe('/api/v1/books/3/cover?t=a1&medium=audio')
})
it('moves between the slots with the arrow keys', async () => {
mountLightbox()
await flushPromises()
@@ -15,6 +15,7 @@ export interface CarouselBook {
title: string | null
coverAspectRatio: CoverAspectRatio
updatedAt?: string | null
coverVersion?: string
seriesIndex?: string | null
hasCover: boolean
authors: string[]
@@ -157,7 +158,7 @@ defineExpose({ scroll })
:style="{ aspectRatio: cardAspectRatio(book) }"
>
<BookCoverArtwork
:src="coverUrl(book.id, 'thumbnail', book.updatedAt)"
:src="coverUrl(book.id, 'thumbnail', book.coverVersion ?? book.updatedAt)"
:has-cover="book.hasCover"
:title="book.title"
:author-line="book.authors.length > 0 ? book.authors.join(', ') : null"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject, nextTick, onUnmounted, reactive, ref, useId, watch } from 'vue'
import { computed, nextTick, onUnmounted, reactive, ref, useId, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Image, ImagePlus, Link, Loader2, Lock, LockOpen, Maximize2, RotateCcw, Search, Upload } from '@lucide/vue'
import type { BookDetail, BookMetadataLockField, CoverMedium } from '@bookorbit/types'
@@ -7,7 +7,7 @@ import { hideOnError } from '../../../lib/metadata-fetch'
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 { useBookCoverAspectRatio } from '../../../composables/useBookCoverAspectRatio'
import { coverFieldMedium, coverLockField, coverTileState, editorTiles } from '../../../lib/cover-slots'
import { nextRovingIndex } from '@/lib/roving-index'
import BookCoverLightbox from '@/features/book/components/BookCoverLightbox.vue'
@@ -23,7 +23,7 @@ const emit = defineEmits<{ coverChanged: [medium: CoverMedium | null]; toggleLoc
const { t } = useI18n()
const { coverUrl } = useCoverVersions()
const { hasPermission } = usePermissions()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const coverAspectRatio = useBookCoverAspectRatio(() => props.book)
const bookIdRef = computed(() => props.book.id)
// One editor per slot, so a pending image on one tile survives selecting the other.
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatDate as formatLocaleDate } from '@/i18n/formatters'
import { formatBytes as formatFileSize } from '@/lib/formatting'
@@ -31,7 +31,7 @@ import { faceMedium } from '@/features/book/lib/cover-slots'
import { readingDateToDateKey } from '@/features/book/lib/reading-date'
import { getProviderColor, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
import { useBookCoverAspectRatio } from '@/features/book/composables/useBookCoverAspectRatio'
import { FORMAT_TO_GROUP, getPrimaryBookFile, READER_OPENABLE_FORMATS } from '@bookorbit/types'
import type { BookDetail, BookKoboState, CustomMetadataBookValue, ReadAloudProgressSync, ReadStatus, UserBookStatus } from '@bookorbit/types'
import { STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS, useBookStatus } from '@/features/book/composables/useBookStatus'
@@ -341,7 +341,7 @@ watch([coverColumnEl, coverActionsEl], ([column, actions]) => {
measureCoverLayout()
})
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const coverAspectRatio = useBookCoverAspectRatio(() => props.book)
const { bookCoverDisplayMode } = useDisplaySettings()
const detailCoverAspectRatio = computed(() => {
if (bookCoverDisplayMode.value !== 'natural-bottom' || !hasCover.value || !coverLoaded.value || coverFailed.value || !coverImageRatio.value) {
@@ -354,7 +354,7 @@ const primaryFile = computed(() => getPrimaryBookFile(props.book.files))
const readAlongFile = computed(() => props.book.files.find((file) => hasReadAlong(file)) ?? null)
const hasAudioFile = computed(() => props.book.files.some((file) => file.format != null && FORMAT_TO_GROUP[file.format] === 'audio'))
const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
// The hero shows the face, whose shape follows the library rather than the primary file once slots exist.
// The hero shows the face: the primary file's slot, or the other one when that slot has no art.
const isFaceAudio = computed(() => {
if (!props.book.covers.ebook && !props.book.covers.audio) return isPrimaryAudio.value
return faceMedium(props.book, coverAspectRatio.value) === 'audio'
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, Loader2, Pencil, X } from '@lucide/vue'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import type { BookCard } from '@bookorbit/types'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../lib/cover-aspect-ratio'
import { useBookCoverAspectRatio } from '../../composables/useBookCoverAspectRatio'
import { useBookDetail } from '../../composables/useBookDetail'
import { useMetadataLocks } from '../../composables/useMetadataLocks'
import { useCoverVersions } from '../../composables/useCoverVersions'
@@ -31,10 +31,10 @@ const mode = ref<'view' | 'edit'>('view')
const { detail, loading, fetch } = useBookDetail()
const { lockedFields, toggle, load: loadLocks } = useMetadataLocks()
const { coverUrl } = useCoverVersions()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const editor = ref<InstanceType<typeof CoverEditorPanel> | null>(null)
const currentDetail = computed(() => (detail.value && detail.value.id === props.book?.id ? detail.value : null))
const coverAspectRatio = useBookCoverAspectRatio(() => currentDetail.value ?? props.book)
const hasCoverResolved = computed(() => (currentDetail.value ? currentDetail.value.coverSource !== null : (props.book?.hasCover ?? false)))
const coverVersion = computed(() => currentDetail.value?.coverVersion ?? props.book?.coverVersion ?? null)
@@ -0,0 +1,13 @@
import { computed, inject, ref, type ComputedRef } from 'vue'
import type { CoverAspectRatio } from '@bookorbit/types'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
/**
* The frame of a book's face. The server sends it per book, following the primary file's medium;
* the surrounding view's shape (the library's) serves only until the book has loaded.
*/
export function useBookCoverAspectRatio(book: () => { coverAspectRatio?: CoverAspectRatio } | null | undefined): ComputedRef<CoverAspectRatio> {
const injected = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
return computed(() => book()?.coverAspectRatio ?? injected.value)
}
@@ -119,6 +119,13 @@ describe('mergeBookCardWithDetail', () => {
expect(result.metadataScore).toBe(95)
})
it("takes the detail's cover frame, keeping the card's when the detail has none", () => {
const book = makeBook({ coverAspectRatio: '2/3' })
expect(mergeBookCardWithDetail(book, makeDetail({ coverAspectRatio: '1/1' })).coverAspectRatio).toBe('1/1')
expect(mergeBookCardWithDetail(book, makeDetail()).coverAspectRatio).toBe('2/3')
})
it('maps authors from detail.authors[].name array', () => {
const book = makeBook()
const detail = makeDetail({
@@ -35,6 +35,7 @@ export function mergeBookCardWithDetail(book: BookCard, detail: BookDetail): Boo
readStatus: detail.readStatus,
metadataScore: detail.metadataScore,
hasCover: detail.coverSource !== null,
coverAspectRatio: detail.coverAspectRatio ?? book.coverAspectRatio,
coverVersion: detail.coverVersion,
hasMetadataLocks: detail.lockedFields.length > 0,
lockedFields: detail.lockedFields,
+4 -1
View File
@@ -26,7 +26,10 @@ export function coverLockField(medium: CoverMedium | null): Extract<BookMetadata
return medium === 'audio' ? 'audioCover' : 'cover'
}
/** Mirrors the server's face rule: a square library shows the audio slot, any other shape the ebook slot. */
/**
* The slot a frame of this shape shows. Given the book's own `coverAspectRatio`, which the server
* derives from the slot it serves, this is the server's face.
*/
export function faceMedium(book: Pick<SlotBook, 'covers'>, aspectRatio: CoverAspectRatio | string): CoverMedium {
const preferred: CoverMedium = aspectRatio === '1/1' ? 'audio' : 'ebook'
if (book.covers[preferred]) return preferred
@@ -58,14 +58,14 @@ const iconChoices = computed(() => (props.icon && !SUGGESTED_ICONS.includes(prop
const coverOptions = computed<{ value: CoverAspectRatio; title: string; hint: string; tiles: string[] }[]>(() => [
{
value: '2/3',
title: t('library.creator.details.cover.portraitTitle'),
hint: t('library.creator.details.cover.portraitHint'),
title: t('library.creator.details.gridShape.tallTitle'),
hint: t('library.creator.details.gridShape.tallHint'),
tiles: ['var(--format-ebook)', 'var(--format-document)', 'var(--format-audio)'],
},
{
value: '1/1',
title: t('library.creator.details.cover.squareTitle'),
hint: t('library.creator.details.cover.squareHint'),
title: t('library.creator.details.gridShape.squareTitle'),
hint: t('library.creator.details.gridShape.squareHint'),
tiles: ['var(--format-kindle)', 'var(--format-comic)', 'var(--format-audio)'],
},
])
@@ -193,9 +193,9 @@ function selectPodcastsType() {
</div>
</LibraryCreatorCard>
<LibraryCreatorCard v-if="type === 'books'" :label="t('library.creator.details.coverStyle.title')">
<LibraryCreatorCard v-if="type === 'books'" :label="t('library.creator.details.gridShape.title')">
<fieldset aria-describedby="cover-style-description">
<legend class="sr-only">{{ t('library.creator.details.coverStyle.title') }}</legend>
<legend class="sr-only">{{ t('library.creator.details.gridShape.title') }}</legend>
<div class="grid gap-2.5 @md:grid-cols-2">
<label
v-for="option in coverOptions"
@@ -231,7 +231,7 @@ function selectPodcastsType() {
</span>
</label>
</div>
<p id="cover-style-description" class="mt-2.5 text-xs text-muted-foreground">{{ t('library.creator.details.cover.slotsHint') }}</p>
<p id="cover-style-description" class="mt-2.5 text-xs text-muted-foreground">{{ t('library.creator.details.gridShape.hint') }}</p>
</fieldset>
</LibraryCreatorCard>
</div>
@@ -214,9 +214,9 @@ async function copyPath(folderId: number, path: string) {
</dd>
</div>
<div class="flex items-center gap-3 border-t border-border py-1.5">
<dt class="shrink-0 text-[12.5px] text-muted-foreground">{{ t('settings.admin.libraries.detail.coverShape') }}</dt>
<dt class="shrink-0 text-[12.5px] text-muted-foreground">{{ t('settings.admin.libraries.detail.gridShape') }}</dt>
<dd class="ms-auto text-[12.5px] font-medium text-foreground">
{{ t(`settings.admin.libraries.detail.coverShapeValue.${library.coverAspectRatio === '1/1' ? 'square' : 'portrait'}`) }}
{{ t(`settings.admin.libraries.detail.gridShapeValue.${library.coverAspectRatio === '1/1' ? 'square' : 'tall'}`) }}
</dd>
</div>
</dl>
+13
View File
@@ -1216,6 +1216,11 @@
"portrait": "2:3 portrait",
"square": "1:1 square"
},
"gridShape": "Grid shape",
"gridShapeValue": {
"tall": "Tall cells",
"square": "Square cells"
},
"none": "None",
"allSupported": "All supported",
"patternCount": "{count, plural, one {# pattern} other {# patterns}}",
@@ -8817,6 +8822,14 @@
"squareTitle": "Square 1:1",
"squareHint": "Audiobook-only libraries",
"slotsHint": "A book with both an ebook and an audiobook shows the cover that fits this shape."
},
"gridShape": {
"title": "Grid shape",
"tallTitle": "Tall cells",
"tallHint": "Best for ebooks or mixed libraries",
"squareTitle": "Square cells",
"squareHint": "Best for audiobook libraries",
"hint": "Each book shows the cover of its primary format, square for an audiobook. A cover that doesn't match the tile follows your cover display setting."
}
},
"folders": {
+1
View File
@@ -35,6 +35,7 @@ const { libraries } = useLibraries()
provide(
COVER_ASPECT_RATIO_KEY,
computed(() => {
if (detail.value?.coverAspectRatio) return detail.value.coverAspectRatio
const libraryId = detail.value?.libraryId
const library = libraryId != null ? libraries.value.find((l) => l.id === libraryId) : null
return library?.coverAspectRatio ?? DEFAULT_COVER_ASPECT_RATIO
+7
View File
@@ -187,6 +187,7 @@ export type BookSeriesMembership = {
export type BookCard = {
id: number;
status: string;
/** The frame of the cover the book shows by default, which follows its primary file's medium rather than its library. */
coverAspectRatio: CoverAspectRatio;
title: string | null;
authors: string[];
@@ -315,6 +316,8 @@ export type BookDetail = {
coverSource: "extracted" | "custom" | null;
coverMedia: CoverMedium[];
covers: Record<CoverMedium, BookCoverSlot | null>;
/** The frame of the cover the book shows by default: square when that is its audiobook art. Absent from servers that predate it. */
coverAspectRatio?: CoverAspectRatio;
coverVersion: string;
hardcoverEditionId: string | null;
providerIds: ProviderIds;
@@ -437,6 +440,8 @@ export type BookRecommendation = {
title: string | null;
coverAspectRatio: CoverAspectRatio;
updatedAt: string | null;
/** The book card's cover cache key. Absent from servers that predate it, where `updatedAt` serves. */
coverVersion?: string;
hasCover: boolean;
authors: string[];
readStatus: UserBookStatus | null;
@@ -452,6 +457,8 @@ export type SeriesBookRecommendation = {
title: string | null;
coverAspectRatio: CoverAspectRatio;
updatedAt: string | null;
/** The book card's cover cache key. Absent from servers that predate it, where `updatedAt` serves. */
coverVersion?: string;
seriesIndex: SeriesIndex | null;
hasCover: boolean;
authors: string[];
@@ -115,7 +115,7 @@ describe('AuthorsService', () => {
enrichmentExecutor as any,
enrichmentOrchestrator as any,
metadataScoreService as any,
{ enrichCardVersions: vi.fn().mockResolvedValue(undefined) } as any,
{ enrichCardFaces: vi.fn().mockResolvedValue(undefined) } as any,
);
libraryService.findAll.mockResolvedValue([{ id: 1 }, { id: 2 }]);
libraryService.findAccessibleLibraryIds.mockResolvedValue([1, 2]);
@@ -177,7 +177,7 @@ export class AuthorsService {
cardData.tagRows,
cardData.seriesMembershipRows,
);
await this.coverStore.enrichCardVersions(items);
await this.coverStore.enrichCardFaces(items);
items.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
return { items, total: page.total, bookTotal: page.total, page: page.page, size: page.size };
@@ -221,7 +221,7 @@ export class AuthorsService {
collapsed.tagRows,
collapsed.seriesMembershipRows,
);
await this.coverStore.enrichCardVersions(items);
await this.coverStore.enrichCardFaces(items);
return { items, total: collapsed.total, bookTotal: collapsed.bookTotal, page, size };
}
@@ -93,6 +93,14 @@ export type BookCoverContext = {
slots: BookCoverSlotRow[];
};
export type BookCoverFaceRow = {
bookId: number;
libraryCoverAspectRatio: string;
primaryFormat: string | null;
media: CoverMedia;
legacyVersion: string;
};
export type BookCoverSourceFile = {
id: number;
absolutePath: string;
@@ -205,6 +213,37 @@ export class BookCoverStoreRepository {
return result;
}
/** What `BookCoverStore.facesFor` needs per book, for lists that carry no files of their own. */
async findFaceRows(bookIds: readonly number[]): Promise<BookCoverFaceRow[]> {
if (bookIds.length === 0) return [];
const outer = outerBookIdSql(books.id);
const rows = await this.db
.select({
bookId: books.id,
coverAspectRatio: libraries.coverAspectRatio,
primaryFormat: sql<string | null>`coalesce(
${bookFiles.format},
(select first_content.format from ${bookFiles} as first_content
where first_content.book_id = ${outer} and first_content.role in ('content', 'primary')
order by first_content.id limit 1)
)`,
updatedAt: books.updatedAt,
addedAt: books.addedAt,
...coverMediaSql(books.id),
})
.from(books)
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.leftJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.where(inArray(books.id, [...bookIds]));
return rows.map((row) => ({
bookId: row.bookId,
libraryCoverAspectRatio: row.coverAspectRatio,
primaryFormat: row.primaryFormat,
media: { hasEbook: row.hasEbook, hasAudio: row.hasAudio },
legacyVersion: (row.updatedAt ?? row.addedAt).toISOString(),
}));
}
async applySlotMutation(
bookId: number,
medium: CoverMedium,
@@ -77,26 +77,98 @@ function makeLegacyStore(root: string, legacy: BookCoverContext) {
}
describe('BookCoverStore', () => {
it('versions the face slot together with its fallback slot', () => {
describe('faceOf', () => {
const { store } = makeStore(join(tmpdir(), 'unused-cover-store'), context());
const ebook = slot('ebook');
const audio = { ...slot('audio'), updatedAt: new Date('2026-02-01T00:00:00.000Z') };
const both = { hasEbook: true, hasAudio: true };
const face = (overrides: Partial<Parameters<BookCoverStore['faceOf']>[0]>) =>
store.faceOf({
libraryCoverAspectRatio: '2/3',
primaryFormat: 'epub',
media: both,
slots: [ebook, audio],
legacyVersion: 'legacy-stamp',
...overrides,
});
expect(store.coverVersion('2/3', [ebook, audio], 'unused')).toBe('ebook:2026-01-01T00:00:00.000Z:2026-02-01T00:00:00.000Z');
expect(store.coverVersion('1/1', [ebook, audio], 'unused')).toBe('audio:2026-02-01T00:00:00.000Z:2026-01-01T00:00:00.000Z');
expect(store.coverVersion('2/3', [{ ...ebook, dormantSince: new Date('2026-03-01T00:00:00.000Z') }, audio], 'unused')).toBe(
'ebook:-:2026-02-01T00:00:00.000Z',
);
it("follows the primary file's medium in either library shape", () => {
expect(face({ primaryFormat: 'm4b' })).toEqual({
coverAspectRatio: '1/1',
coverVersion: 'audio:2026-02-01T00:00:00.000Z:2026-01-01T00:00:00.000Z',
});
expect(face({ libraryCoverAspectRatio: '1/1', primaryFormat: 'EPUB' })).toEqual({
coverAspectRatio: '2/3',
coverVersion: 'ebook:2026-01-01T00:00:00.000Z:2026-02-01T00:00:00.000Z',
});
});
it('takes the library shape only for a book with no content file', () => {
const none = { hasEbook: false, hasAudio: false };
expect(face({ libraryCoverAspectRatio: '1/1', primaryFormat: null, media: none, slots: [] })).toEqual({
coverAspectRatio: '1/1',
coverVersion: 'legacy:legacy-stamp',
});
expect(face({ primaryFormat: undefined, media: none, slots: [] }).coverAspectRatio).toBe('2/3');
});
it('frames the slot it falls back to when the preferred one is empty, keeping the preferred medium in the version', () => {
expect(face({ primaryFormat: 'm4b', slots: [ebook] })).toEqual({ coverAspectRatio: '2/3', coverVersion: 'audio:-:2026-01-01T00:00:00.000Z' });
expect(face({ primaryFormat: 'm4b', slots: [ebook, { ...audio, dormantSince: new Date('2026-03-01T00:00:00.000Z') }] }).coverAspectRatio).toBe(
'2/3',
);
expect(face({ primaryFormat: 'm4b', media: { hasEbook: false, hasAudio: true }, slots: [] }).coverAspectRatio).toBe('1/1');
});
it('keeps a read-along EPUB on the ebook side', () => {
expect(face({ primaryFormat: 'epub', media: { hasEbook: true, hasAudio: true }, slots: [ebook, audio] }).coverAspectRatio).toBe('2/3');
});
});
it('resolves the library face and honors explicit strict and fallback reads', async () => {
it('gives cards the face of their primary file and replaces the library shape they arrive with', async () => {
const { store, repository } = makeStore(join(tmpdir(), 'unused-cover-store'), context());
repository.slotsFor.mockResolvedValue(new Map([[7, [slot('ebook'), slot('audio')]]]));
const card = {
id: 7,
coverAspectRatio: '2/3',
addedAt: '2026-01-01T00:00:00.000Z',
updatedAt: null,
coverVersion: 'legacy:x',
files: [
{ id: 70, format: 'epub', role: 'content', sizeBytes: null },
{ id: 71, format: 'm4b', role: 'primary', sizeBytes: null },
],
};
await store.enrichCardFaces([card as never]);
expect(card).toMatchObject({ coverAspectRatio: '1/1', coverVersion: 'audio:2026-01-01T00:00:00.000Z:2026-01-01T00:00:00.000Z' });
});
it('resolves faces for rows with no files of their own from one row and one slot query', async () => {
const { store, repository } = makeStore(join(tmpdir(), 'unused-cover-store'), context());
const findFaceRows = vi
.fn()
.mockResolvedValue([
{ bookId: 7, libraryCoverAspectRatio: '2/3', primaryFormat: 'm4b', media: { hasEbook: true, hasAudio: true }, legacyVersion: 'x' },
]);
Object.assign(repository, { findFaceRows });
repository.slotsFor.mockResolvedValue(new Map([[7, [slot('ebook'), slot('audio')]]]));
const faces = await store.facesFor([7, 7]);
expect(findFaceRows).toHaveBeenCalledWith([7]);
expect(faces.get(7)?.coverAspectRatio).toBe('1/1');
});
it("resolves the primary file's face and honors explicit strict and fallback reads", async () => {
const root = await mkdtemp(join(tmpdir(), 'cover-store-resolve-'));
try {
const ebookDir = bookCoverSlotDirPath(root, 7, 'ebook');
const audioDir = bookCoverSlotDirPath(root, 7, 'audio');
await Promise.all([mkdir(ebookDir, { recursive: true }), mkdir(audioDir, { recursive: true })]);
await Promise.all([writeFile(join(ebookDir, 'cover_extracted.jpg'), 'ebook'), writeFile(join(audioDir, 'cover_extracted.jpg'), 'audio')]);
const { store } = makeStore(root, context({ coverAspectRatio: '1/1' }));
const { store } = makeStore(root, context({ primaryFileId: 71 }));
await expect(store.resolve(7, { variant: 'cover' })).resolves.toBe(join(audioDir, 'cover_extracted.jpg'));
await expect(store.resolve(7, { medium: 'ebook', variant: 'cover', strict: true })).resolves.toBe(join(ebookDir, 'cover_extracted.jpg'));
@@ -353,13 +425,13 @@ describe('BookCoverStore', () => {
await expect(readAlong.store.chooseSidecarMedium(7, await image(80, 80))).resolves.toBe('ebook');
});
it('writes image bytes by shape, and a write with no image to the face slot of the library shape', async () => {
const portrait = makeStore(join(tmpdir(), 'unused-cover-store'), context());
await expect(portrait.store.chooseWriteMedium(7, await image(80, 80))).resolves.toBe('audio');
await expect(portrait.store.chooseWriteMedium(7)).resolves.toBe('ebook');
it("writes image bytes by shape, and a write with no image to the face slot of the primary file's medium", async () => {
const ebookFirst = makeStore(join(tmpdir(), 'unused-cover-store'), context({ coverAspectRatio: '1/1' }));
await expect(ebookFirst.store.chooseWriteMedium(7, await image(80, 80))).resolves.toBe('audio');
await expect(ebookFirst.store.chooseWriteMedium(7)).resolves.toBe('ebook');
const square = makeStore(join(tmpdir(), 'unused-cover-store'), context({ coverAspectRatio: '1/1' }));
await expect(square.store.chooseWriteMedium(7)).resolves.toBe('audio');
const audioFirst = makeStore(join(tmpdir(), 'unused-cover-store'), context({ primaryFileId: 71 }));
await expect(audioFirst.store.chooseWriteMedium(7)).resolves.toBe('audio');
});
it('hands a merged book’s slot to the target only for a medium the target has no cover for', async () => {
@@ -1,6 +1,14 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { COVER_MEDIA, isAudioFormat, type BookCard, type BookCoverSlot, type CoverMedia, type CoverMedium } from '@bookorbit/types';
import {
COVER_MEDIA,
isAudioFormat,
type BookCard,
type BookCoverSlot,
type CoverAspectRatio,
type CoverMedia,
type CoverMedium,
} from '@bookorbit/types';
import { randomUUID } from 'crypto';
import { access, copyFile, cp, link, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'fs/promises';
import { basename, join } from 'path';
@@ -23,7 +31,9 @@ import { BookCoverEventsService } from './book-cover-events.service';
import { classifyCoverShape } from './cover-shape';
import {
BookCoverStoreRepository,
getCoverMediaFromDatabaseFiles,
type BookCoverContext,
type BookCoverFaceRow,
type BookCoverOrigin,
type BookCoverSlotRow,
type BookCoverSource,
@@ -43,6 +53,13 @@ type SaveOptions = {
type SlotStateOptions = { backfill?: boolean };
export type BookCoverFaceInput = Omit<BookCoverFaceRow, 'bookId' | 'primaryFormat'> & {
primaryFormat: string | null | undefined;
slots: readonly BookCoverSlotRow[];
};
export type BookCoverFace = { coverAspectRatio: CoverAspectRatio; coverVersion: string };
// A grid of books with missing thumbnails would otherwise resize every cover at once.
const THUMBNAIL_REPAIR_CONCURRENCY = 4;
@@ -368,18 +385,7 @@ export class BookCoverStore {
async faceMediumFor(bookId: number): Promise<CoverMedium> {
const context = await this.requireContext(bookId);
const preferred = this.faceMedium(context);
const alternate = preferred === 'ebook' ? 'audio' : 'ebook';
const active = new Set(
context.slots
.filter((slot) => slot.dormantSince === null && this.mediumIsActive(slot.medium as CoverMedium, context.media))
.map((slot) => slot.medium as CoverMedium),
);
if (active.has(preferred)) return preferred;
if (active.has(alternate)) return alternate;
if (this.mediumIsActive(preferred, context.media)) return preferred;
if (this.mediumIsActive(alternate, context.media)) return alternate;
return preferred;
return this.servedFace(this.faceMedium(context), context.slots, context.media);
}
async mediaFor(bookId: number): Promise<CoverMedia> {
@@ -410,10 +416,55 @@ export class BookCoverStore {
return this.repository.slotsFor(bookIds);
}
async enrichCardVersions(cards: BookCard[]): Promise<void> {
/**
* The cover a book shows when no medium is asked for, as the frame it is drawn in and its cache
* key. The face follows the primary file's medium, so an audiobook-primary book shows square art
* in any library; only a book with no content file takes its library's shape. The frame follows
* the slot actually served, so a book whose preferred slot is empty is framed for its fallback.
*/
faceOf(input: BookCoverFaceInput): BookCoverFace {
const preferred = this.preferredFace(input.libraryCoverAspectRatio, input.primaryFormat);
return {
coverAspectRatio: this.servedFace(preferred, input.slots, input.media) === 'audio' ? '1/1' : '2/3',
coverVersion: this.coverVersion(preferred, input.slots, input.legacyVersion),
};
}
faceForContext(context: BookCoverContext, legacyVersion: string): BookCoverFace {
return this.faceOf({
libraryCoverAspectRatio: context.coverAspectRatio,
primaryFormat: this.primaryFile(context)?.format,
media: context.media,
slots: context.slots,
legacyVersion,
});
}
/** Faces for rows that carry no files of their own: two queries, whatever the number of books. */
async facesFor(bookIds: readonly number[]): Promise<Map<number, BookCoverFace>> {
const ids = [...new Set(bookIds)];
const faces = new Map<number, BookCoverFace>();
if (ids.length === 0) return faces;
const [rows, slotsByBookId] = await Promise.all([this.repository.findFaceRows(ids), this.repository.slotsFor(ids)]);
for (const row of rows) faces.set(row.bookId, this.faceOf({ ...row, slots: slotsByBookId.get(row.bookId) ?? [] }));
return faces;
}
/** Cards arrive carrying their library's shape, which this replaces with the shape of their face. */
async enrichCardFaces(cards: BookCard[]): Promise<void> {
const slotsByBookId = await this.repository.slotsFor(cards.map((card) => card.id));
for (const card of cards) {
card.coverVersion = this.coverVersion(card.coverAspectRatio, slotsByBookId.get(card.id) ?? [], card.updatedAt ?? card.addedAt);
const face = this.faceOf({
libraryCoverAspectRatio: card.coverAspectRatio,
primaryFormat: card.files.find((file) => file.role === 'primary')?.format,
media: getCoverMediaFromDatabaseFiles(
card.files.map((file) => ({ format: file.format, role: file.role, mediaOverlayAvailable: file.mediaOverlay?.available === true })),
),
slots: slotsByBookId.get(card.id) ?? [],
legacyVersion: card.updatedAt ?? card.addedAt,
});
card.coverAspectRatio = face.coverAspectRatio;
card.coverVersion = face.coverVersion;
}
}
@@ -424,9 +475,8 @@ export class BookCoverStore {
};
}
coverVersion(coverAspectRatio: string, slots: readonly BookCoverSlotRow[], legacyVersion: string): string {
private coverVersion(face: CoverMedium, slots: readonly BookCoverSlotRow[], legacyVersion: string): string {
if (slots.length === 0) return `legacy:${legacyVersion}`;
const face: CoverMedium = coverAspectRatio === '1/1' ? 'audio' : 'ebook';
const other: CoverMedium = face === 'ebook' ? 'audio' : 'ebook';
const stamp = (medium: CoverMedium) => slots.find((slot) => slot.medium === medium && slot.dormantSince === null)?.updatedAt.toISOString() ?? '-';
return `${face}:${stamp(face)}:${stamp(other)}`;
@@ -616,10 +666,14 @@ export class BookCoverStore {
}
private primaryMedium(context: BookCoverContext): CoverMedium {
const primary = context.files.find((file) => file.id === context.primaryFileId) ?? context.files.find((file) => this.isContent(file.role));
const primary = this.primaryFile(context);
return primary?.format ? this.formatMedium(primary.format) : 'ebook';
}
private primaryFile(context: Pick<BookCoverContext, 'files' | 'primaryFileId'>): BookCoverContext['files'][number] | undefined {
return context.files.find((file) => file.id === context.primaryFileId) ?? context.files.find((file) => this.isContent(file.role));
}
private isContent(role: string): boolean {
return role === 'content' || role === 'primary';
}
@@ -691,8 +745,25 @@ export class BookCoverStore {
return [face, face === 'ebook' ? 'audio' : 'ebook'];
}
private faceMedium(context: Pick<BookCoverContext, 'coverAspectRatio'>): CoverMedium {
return context.coverAspectRatio === '1/1' ? 'audio' : 'ebook';
private faceMedium(context: BookCoverContext): CoverMedium {
return this.preferredFace(context.coverAspectRatio, this.primaryFile(context)?.format);
}
private preferredFace(libraryCoverAspectRatio: string, primaryFormat: string | null | undefined): CoverMedium {
if (primaryFormat) return this.formatMedium(primaryFormat);
return libraryCoverAspectRatio === '1/1' ? 'audio' : 'ebook';
}
/** The preferred slot when it holds art, else the other one; with no art at all, a medium the book has. */
private servedFace(preferred: CoverMedium, slots: readonly BookCoverSlotRow[], media: CoverMedia): CoverMedium {
const alternate: CoverMedium = preferred === 'ebook' ? 'audio' : 'ebook';
const filled = (medium: CoverMedium) =>
this.mediumIsActive(medium, media) && slots.some((slot) => slot.medium === medium && slot.dormantSince === null);
if (filled(preferred)) return preferred;
if (filled(alternate)) return alternate;
if (this.mediumIsActive(preferred, media)) return preferred;
if (this.mediumIsActive(alternate, media)) return alternate;
return preferred;
}
private mediumIsActive(medium: CoverMedium, media: CoverMedia): boolean {
+1 -1
View File
@@ -270,7 +270,7 @@ function makeService(overrides: { bookMetadataLockService?: unknown; appDataPath
const coverStore = {
resolve: vi.fn().mockResolvedValue(null),
fetchState: vi.fn().mockResolvedValue({ media: { hasEbook: true, hasAudio: false }, filled: { ebook: false, audio: false }, locked: [] }),
enrichCardVersions: vi.fn().mockResolvedValue(undefined),
enrichCardFaces: vi.fn().mockResolvedValue(undefined),
contextFor: vi.fn().mockResolvedValue(null),
slotDtos: vi.fn().mockReturnValue({ ebook: null, audio: null }),
coverVersion: vi.fn((_coverAspectRatio: string, _slots: unknown[], legacyVersion: string) => `legacy:${legacyVersion}`),
+6 -10
View File
@@ -1152,7 +1152,7 @@ export class BookService {
tagRows,
seriesMembershipRows,
);
await this.coverStore.enrichCardVersions(cards);
await this.coverStore.enrichCardFaces(cards);
const result = {
items: cards,
total,
@@ -1195,7 +1195,7 @@ export class BookService {
seriesMembershipRows,
customMetadataRows,
);
await this.coverStore.enrichCardVersions(cards);
await this.coverStore.enrichCardFaces(cards);
const result = {
items: cards,
total,
@@ -3268,7 +3268,7 @@ export class BookService {
this.bookRepo.findReadAloudSyncMode(user.id, id),
this.coverStore.contextFor(id),
]);
if (!result) throw new NotFoundException(`Book ${id} not found`);
if (!result || !coverContext) throw new NotFoundException(`Book ${id} not found`);
const { book, authorRows, genreRows, tagRows, fileRows, narratorRows, seriesMembershipRows, communityRatingRows } = result;
const meta = book.book_metadata;
@@ -3318,13 +3318,9 @@ export class BookService {
personalNoteUpdatedAt: personalNote ? new Date(personalNote.updatedAt) : null,
communityRatings: this.mapCommunityRatingRows(communityRatingRows),
coverSource: (meta?.coverSource as 'extracted' | 'custom' | null) ?? null,
coverMedia: [...(coverContext?.media.hasEbook ? (['ebook'] as const) : []), ...(coverContext?.media.hasAudio ? (['audio'] as const) : [])],
covers: coverContext ? this.coverStore.slotDtos(coverContext) : { ebook: null, audio: null },
coverVersion: this.coverStore.coverVersion(
book.libraries?.coverAspectRatio ?? '2/3',
coverContext?.slots ?? [],
(meta?.coverUpdatedAt ?? book.books.updatedAt ?? book.books.addedAt).toISOString(),
),
coverMedia: [...(coverContext.media.hasEbook ? (['ebook'] as const) : []), ...(coverContext.media.hasAudio ? (['audio'] as const) : [])],
covers: this.coverStore.slotDtos(coverContext),
...this.coverStore.faceForContext(coverContext, (meta?.coverUpdatedAt ?? book.books.updatedAt ?? book.books.addedAt).toISOString()),
hardcoverEditionId: meta?.hardcoverEditionId ?? null,
lockedFields: this.bookMetadataLockService.normalizeLockedFields(meta?.lockedFields),
providerIds: {
@@ -7,6 +7,7 @@ import type {
CustomMetadataBookValue,
ComicMetadataFields,
BookCoverSlot,
CoverAspectRatio,
CoverMedium,
NarratorRef,
ProviderIds,
@@ -63,6 +64,8 @@ export class BookDetailDto {
coverSource: 'extracted' | 'custom' | null;
coverMedia: CoverMedium[];
covers: Record<CoverMedium, BookCoverSlot | null>;
/** The frame of the cover the book shows by default: square when that is its audiobook art. */
coverAspectRatio: CoverAspectRatio;
coverVersion: string;
hardcoverEditionId: string | null;
providerIds: ProviderIds;
@@ -50,7 +50,7 @@ function makeService() {
findOne: vi.fn().mockResolvedValue({ mediaType: 'books' }),
};
const coverStore = { enrichCardVersions: vi.fn().mockResolvedValue(undefined) };
const coverStore = { enrichCardFaces: vi.fn().mockResolvedValue(undefined) };
const service = new DashboardService(
dashboardRepo as never,
bookReadService as never,
@@ -35,7 +35,7 @@ export class DashboardService {
userId,
);
const cards = assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows, narratorRows, tagRows);
await this.coverStore.enrichCardVersions(cards);
await this.coverStore.enrichCardFaces(cards);
const cardsById = new Map(cards.map((card) => [card.id, card]));
return bookIds.map((id) => cardsById.get(id)).filter((card): card is BookCard => card != null);
}
@@ -158,15 +158,13 @@ describe('toNotebookBookLabel', () => {
title: 'Dune',
author: 'Frank Herbert, Brian Herbert',
coverSource: 'extracted',
coverAspectRatio: '1/1',
updatedAt: updated,
readFileId: 77,
readFileFormat: 'EPUB',
hasAudio: true,
};
it('maps the label with the given cover version', () => {
expect(toNotebookBookLabel(row, 'audio:x:-')).toEqual({
it('maps the label with the given cover face', () => {
expect(toNotebookBookLabel(row, { coverVersion: 'audio:x:-', coverAspectRatio: '1/1' })).toEqual({
id: 5,
title: 'Dune',
author: 'Frank Herbert, Brian Herbert',
@@ -179,12 +177,14 @@ describe('toNotebookBookLabel', () => {
});
});
it('falls back to the portrait ratio and reports a book with nothing to read', () => {
expect(
toNotebookBookLabel(
{ ...row, coverSource: null, coverAspectRatio: 'odd', readFileId: null, readFileFormat: null, hasAudio: false },
'legacy:x',
),
).toMatchObject({ hasCover: false, coverAspectRatio: '2/3', readFileId: null, readFileFormat: null, hasAudio: false });
it('falls back to a portrait frame without a face and reports a book with nothing to read', () => {
expect(toNotebookBookLabel({ ...row, coverSource: null, readFileId: null, readFileFormat: null, hasAudio: false }, undefined)).toMatchObject({
hasCover: false,
coverVersion: null,
coverAspectRatio: '2/3',
readFileId: null,
readFileFormat: null,
hasAudio: false,
});
});
});
@@ -1,6 +1,6 @@
import {
normalizeCoverAspectRatio,
type AnnotationPositionStatus,
type CoverAspectRatio,
type NotebookBookLabel,
type NotebookBookmarkItem,
type NotebookHighlightEntry,
@@ -60,8 +60,6 @@ export interface NotebookLabelRow {
title: string | null;
author: string | null;
coverSource: string | null;
coverAspectRatio: string;
updatedAt: Date;
readFileId: number | null;
readFileFormat: string | null;
hasAudio: boolean;
@@ -147,15 +145,18 @@ export function toNotebookReview(row: NotebookReviewRow): NotebookReviewItem {
};
}
/** `coverVersion` is computed by the cover store, exactly as it is for a library book card. */
export function toNotebookBookLabel(row: NotebookLabelRow, coverVersion: string): NotebookBookLabel {
/** The face is computed by the cover store, exactly as it is for a library book card. */
export function toNotebookBookLabel(
row: NotebookLabelRow,
face: { coverVersion: string; coverAspectRatio: CoverAspectRatio } | undefined,
): NotebookBookLabel {
return {
id: row.id,
title: row.title ?? null,
author: row.author ?? null,
hasCover: row.coverSource != null,
coverVersion,
coverAspectRatio: normalizeCoverAspectRatio(row.coverAspectRatio),
coverVersion: face?.coverVersion ?? null,
coverAspectRatio: face?.coverAspectRatio ?? '2/3',
readFileId: row.readFileId ?? null,
readFileFormat: row.readFileFormat ? row.readFileFormat.toLowerCase() : null,
hasAudio: row.hasAudio === true,
@@ -16,7 +16,6 @@ import {
bookMetadata,
bookmarks,
books,
libraries,
userBookNotes,
userBookRatings,
} from '../../db/schema';
@@ -335,8 +334,6 @@ export class NotebookRepository {
where ${bookAuthors.bookId} = ${books.id}
)`,
coverSource: bookMetadata.coverSource,
coverAspectRatio: libraries.coverAspectRatio,
updatedAt: books.updatedAt,
readFileId: readFileSql<number | null>(sql`${bookFiles.id}`),
readFileFormat: readFileSql<string | null>(sql`lower(${bookFiles.format})`),
hasAudio: sql<boolean>`exists (
@@ -347,7 +344,6 @@ export class NotebookRepository {
)`,
})
.from(books)
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(inArray(books.id, [...bookIds]));
}
@@ -57,8 +57,6 @@ function labelRow(id: number) {
title: `Book ${id}`,
author: 'Author',
coverSource: 'extracted',
coverAspectRatio: '2/3',
updatedAt: at,
readFileId: id * 10,
readFileFormat: 'epub',
hasAudio: false,
@@ -88,8 +86,9 @@ function makeService(libraryIds = [1, 2]) {
};
const libraryService = { findAccessibleLibraryIds: vi.fn().mockResolvedValue(libraryIds) };
const coverStore = {
slotsFor: vi.fn().mockResolvedValue(new Map()),
coverVersion: vi.fn((ratio: string, _slots: unknown[], legacy: string) => `legacy:${ratio}:${legacy}`),
facesFor: vi.fn((ids: number[]) =>
Promise.resolve(new Map(ids.map((id) => [id, { coverVersion: `legacy:${at.toISOString()}`, coverAspectRatio: id === 6 ? '1/1' : '2/3' }]))),
),
};
const service = new NotebookService(repo as never, libraryService as never, coverStore as never);
return { service, repo, libraryService, coverStore };
@@ -152,7 +151,13 @@ describe('NotebookService.entries', () => {
]);
expect(repo.findJournal).toHaveBeenCalledWith(8, []);
expect(Object.keys(result.books).sort()).toEqual(['5', '6']);
expect(result.books['6']).toMatchObject({ id: 6, title: 'Book 6', coverVersion: `legacy:2/3:${at.toISOString()}`, readFileId: 60 });
expect(result.books['6']).toMatchObject({
id: 6,
title: 'Book 6',
coverVersion: `legacy:${at.toISOString()}`,
coverAspectRatio: '1/1',
readFileId: 60,
});
expect(decodeNotebookCursor(result.nextCursor!, 'newest')).toEqual({ scope: 'newest', key: '2026-02-03T04:05:06.123456Z', rank: 0, id: 11 });
});
@@ -7,7 +7,6 @@ import {
NOTEBOOK_PAGE_SIZE_DEFAULT,
NOTEBOOK_REVIEW_DAILY_SIZE,
NOTEBOOK_REVIEW_SHUFFLE_MAX,
normalizeCoverAspectRatio,
type AnnotationItem,
type NotebookBookLabel,
type NotebookBookRow,
@@ -261,20 +260,13 @@ export class NotebookService {
return entries;
}
/** The label sidecar: one query for the books and one for their cover slots, whatever the page size. */
/** The label sidecar: one query for the books and two for their covers, whatever the page size. */
private async labels(bookIds: readonly number[]): Promise<Record<string, NotebookBookLabel>> {
const ids = [...new Set(bookIds)];
if (ids.length === 0) return {};
const [rows, slotsByBook] = await Promise.all([this.repo.findLabels(ids), this.coverStore.slotsFor(ids)]);
const [rows, faces] = await Promise.all([this.repo.findLabels(ids), this.coverStore.facesFor(ids)]);
const labels: Record<string, NotebookBookLabel> = {};
for (const row of rows) {
const coverVersion = this.coverStore.coverVersion(
normalizeCoverAspectRatio(row.coverAspectRatio),
slotsByBook.get(row.id) ?? [],
row.updatedAt.toISOString(),
);
labels[String(row.id)] = toNotebookBookLabel(row, coverVersion);
}
for (const row of rows) labels[String(row.id)] = toNotebookBookLabel(row, faces.get(row.id));
return labels;
}
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { BookModule } from '../book/book.module';
import { BookCoverStoreModule } from '../book-cover-store/book-cover-store.module';
import { EmbeddingModule } from '../embedding/embedding.module';
import { LibraryModule } from '../library/library.module';
import { UserBookStatusModule } from '../user-book-status/user-book-status.module';
@@ -9,7 +10,7 @@ import { RecommendationRepository } from './recommendation.repository';
import { RecommendationService } from './recommendation.service';
@Module({
imports: [BookModule, LibraryModule, EmbeddingModule, UserBookStatusModule],
imports: [BookModule, BookCoverStoreModule, LibraryModule, EmbeddingModule, UserBookStatusModule],
controllers: [RecommendationController],
providers: [RecommendationService, RecommendationRepository],
exports: [RecommendationService],
@@ -47,6 +47,9 @@ function makeService() {
const userBookStatusService = {
findByBookIds: vi.fn().mockResolvedValue(new Map()),
};
const coverStore = {
facesFor: vi.fn().mockResolvedValue(new Map()),
};
const service = new RecommendationService(
recRepo as never,
@@ -54,9 +57,10 @@ function makeService() {
libraryService as never,
embedder as never,
userBookStatusService as never,
coverStore as never,
);
return { service, recRepo, bookRepo, libraryService, embedder, userBookStatusService };
return { service, recRepo, bookRepo, libraryService, embedder, userBookStatusService, coverStore };
}
describe('RecommendationService', () => {
@@ -540,6 +544,24 @@ describe('RecommendationService', () => {
expect(result[1].readStatus).toBeNull();
});
it("replaces the library's shape with each book's cover face", async () => {
const { service, bookRepo, recRepo, libraryService, coverStore } = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(5);
libraryService.findAccessibleLibraryIds.mockResolvedValue([5]);
recRepo.findAuthorBooks.mockResolvedValue([
{ bookId: 10, title: 'Audio First', coverAspectRatio: '2/3', updatedAt: null, coverSource: 'extracted', authorNames: [], isAudiobook: true },
{ bookId: 20, title: 'No Face', coverAspectRatio: '2/3', updatedAt: null, coverSource: null, authorNames: [], isAudiobook: false },
]);
coverStore.facesFor.mockResolvedValue(new Map([[10, { coverAspectRatio: '1/1', coverVersion: 'audio:a:b' }]]));
const result = await service.getAuthorBooks(1, makeUser());
expect(coverStore.facesFor).toHaveBeenCalledWith([10, 20]);
expect(result[0]).toMatchObject({ coverAspectRatio: '1/1', coverVersion: 'audio:a:b' });
expect(result[1].coverAspectRatio).toBe('2/3');
expect(result[1].coverVersion).toBeUndefined();
});
it('returns empty array when author has no other books', async () => {
const { service, bookRepo, recRepo, libraryService } = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(5);
@@ -1,8 +1,9 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { BookRecommendation, SeriesBookRecommendation, UnscopedBookRecommendation, UserBookStatus } from '@bookorbit/types';
import type { BookRecommendation, CoverAspectRatio, SeriesBookRecommendation, UnscopedBookRecommendation, UserBookStatus } from '@bookorbit/types';
import { normalizeCoverAspectRatio } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { BookCoverStore } from '../book-cover-store/book-cover-store.service';
import { BookEmbedderService } from '../embedding/book-embedder.service';
import { BookReadService } from '../book/book-read.service';
import { LibraryService } from '../library/library.service';
@@ -34,14 +35,17 @@ export class RecommendationService {
private readonly libraryService: LibraryService,
private readonly embedder: BookEmbedderService,
private readonly userBookStatusService: UserBookStatusService,
private readonly coverStore: BookCoverStore,
) {}
private async withReadStatus<T extends { id: number }>(rows: T[], userId: number): Promise<(T & { readStatus: UserBookStatus | null })[]> {
const statuses = await this.userBookStatusService.findByBookIds(
userId,
rows.map((row) => row.id),
);
return rows.map((row) => ({ ...row, readStatus: statuses.get(row.id) ?? null }));
/** Adds the viewer's read status and each book's cover face, which replaces the library shape the rows carry. */
private async withCoverAndReadStatus<T extends { id: number; coverAspectRatio: CoverAspectRatio }>(
rows: T[],
userId: number,
): Promise<(T & { coverVersion?: string; readStatus: UserBookStatus | null })[]> {
const ids = rows.map((row) => row.id);
const [statuses, faces] = await Promise.all([this.userBookStatusService.findByBookIds(userId, ids), this.coverStore.facesFor(ids)]);
return rows.map((row) => ({ ...row, ...faces.get(row.id), readStatus: statuses.get(row.id) ?? null }));
}
async getRecommendations(bookId: number, user: RequestUser): Promise<BookRecommendation[]> {
@@ -104,7 +108,7 @@ export class RecommendationService {
const ordered = rescored
.map((rescoredCandidate) => rowMap.get(rescoredCandidate.bookId))
.filter((row): row is UnscopedBookRecommendation => row != null);
const recommendations = await this.withReadStatus(ordered, user.id);
const recommendations = await this.withCoverAndReadStatus(ordered, user.id);
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`,
@@ -144,7 +148,7 @@ export class RecommendationService {
`[${SERIES_BOOKS_EVENT}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} seriesId=${series.id} seriesName="${sanitizeLogValue(series.name ?? '')}" resultCount=${rows.length} - series books lookup completed`,
);
return this.withReadStatus(
return this.withCoverAndReadStatus(
rows.map((r) => ({
id: r.bookId,
title: r.title,
@@ -183,7 +187,7 @@ export class RecommendationService {
`[${AUTHOR_BOOKS_EVENT}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} resultCount=${rows.length} - author books lookup completed`,
);
return this.withReadStatus(
return this.withCoverAndReadStatus(
rows.map((r) => ({
id: r.bookId,
title: r.title,
@@ -162,7 +162,7 @@ const mockMetadata = {
function makeService(
repo: ReturnType<typeof makeRepo>,
autoFetchOrchestrator?: { scheduleImportedBooksIfEligible: (...args: unknown[]) => Promise<number> },
coverStore: { enrichCardVersions: (cards: unknown[]) => Promise<void> } = { enrichCardVersions: vi.fn().mockResolvedValue(undefined) },
coverStore: { enrichCardFaces: (cards: unknown[]) => Promise<void> } = { enrichCardFaces: vi.fn().mockResolvedValue(undefined) },
coverReconciler?: { enqueue: (...args: unknown[]) => Promise<void>; enqueueExpiredDormant?: (libraryId: number) => Promise<void> },
) {
const jobStore = new ScanJobStore();
@@ -470,7 +470,7 @@ export class ScannerService implements OnApplicationBootstrap {
private async buildAndEmitBookCards(libraryId: number, bookIds: number[]): Promise<void> {
const { rows, authorRows, fileRows, genreRows } = await this.scannerRepo.findBookCardData(bookIds);
const cards = assembleBookCards(rows, authorRows, fileRows, genreRows, []);
await this.coverStore.enrichCardVersions(cards);
await this.coverStore.enrichCardFaces(cards);
if (cards.length > 0) {
this.scanGateway.emitBooksAdded({ libraryId, books: cards } satisfies ScanBooksAddedEvent);
}
@@ -69,7 +69,7 @@ describe('SeriesService', () => {
seriesRepo as any,
bookReadService as any,
libraryService as any,
{ enrichCardVersions: vi.fn().mockResolvedValue(undefined) } as any,
{ enrichCardFaces: vi.fn().mockResolvedValue(undefined) } as any,
);
libraryService.findAll.mockResolvedValue([{ id: 1 }, { id: 2 }]);
libraryService.findAccessibleLibraryIds.mockResolvedValue([1, 2]);
+1 -1
View File
@@ -164,7 +164,7 @@ export class SeriesService {
cardData.tagRows,
cardData.seriesMembershipRows,
);
await this.coverStore.enrichCardVersions(cards);
await this.coverStore.enrichCardFaces(cards);
const orderMap = new Map(bookPage.bookIds.map((id, i) => [id, i]));
items = cards
.map((card) => {