mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -1,17 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { X } from '@lucide/vue'
|
import { X } from '@lucide/vue'
|
||||||
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
|
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
|
||||||
import type { BookDetail, CoverMedium } from '@bookorbit/types'
|
import type { BookDetail, CoverMedium } from '@bookorbit/types'
|
||||||
import { useCoverVersions } from '../composables/useCoverVersions'
|
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 { faceMedium } from '../lib/cover-slots'
|
||||||
import CoverMediumSwitch from './CoverMediumSwitch.vue'
|
import CoverMediumSwitch from './CoverMediumSwitch.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
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. */
|
/** The slot to open on. Without one the lightbox opens on the face, as the page shows it. */
|
||||||
medium?: CoverMedium | null
|
medium?: CoverMedium | null
|
||||||
/** Unsaved editor images, shown in place of the saved slot they would replace. */
|
/** 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 { t } = useI18n()
|
||||||
const { coverUrl } = useCoverVersions()
|
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 {
|
function hasImage(medium: CoverMedium): boolean {
|
||||||
return Boolean(props.previews?.[medium]) || props.book.covers[medium] !== null
|
return Boolean(props.previews?.[medium]) || props.book.covers[medium] !== null
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { BookOpen, ExternalLink, Eye, Folder, FolderPlus, Headphones, Pencil, Star, Trash2, X } from '@lucide/vue'
|
import { BookOpen, ExternalLink, Eye, Folder, FolderPlus, Headphones, Pencil, Star, Trash2, X } from '@lucide/vue'
|
||||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||||
@@ -15,7 +15,7 @@ import BookFormatChip from './BookFormatChip.vue'
|
|||||||
import { bookFormatEntries, fileFormatKey } from '../lib/book-formats'
|
import { bookFormatEntries, fileFormatKey } from '../lib/book-formats'
|
||||||
import { displayPublishedDate } from '../lib/published-date'
|
import { displayPublishedDate } from '../lib/published-date'
|
||||||
import { FORMAT_TO_GROUP, getPrimaryBookFile } from '@bookorbit/types'
|
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 { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useSafeHtml } from '@/features/book/composables/useSafeHtml'
|
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 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 { bookCoverDisplayMode } = useDisplaySettings()
|
||||||
const quickViewCoverAspectRatio = computed(() => {
|
const quickViewCoverAspectRatio = computed(() => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, ref, watch } from 'vue'
|
import { computed, inject, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 { Library } from '@lucide/vue'
|
||||||
import BookCoverArtwork from './BookCoverArtwork.vue'
|
import BookCoverArtwork from './BookCoverArtwork.vue'
|
||||||
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
|
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
|
||||||
@@ -16,6 +16,8 @@ const STACK_COVER_STEP_PCT = 8
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
book: BookCard
|
book: BookCard
|
||||||
|
/** A grid's own tile shape, which wins over the book's. */
|
||||||
|
coverAspectRatio?: CoverAspectRatio
|
||||||
showLabel?: boolean
|
showLabel?: boolean
|
||||||
selectionMode?: boolean
|
selectionMode?: boolean
|
||||||
}>()
|
}>()
|
||||||
@@ -23,7 +25,7 @@ const props = defineProps<{
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const injectedCoverAspectRatio = 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 effectiveCoverAspectRatio = computed(() => props.coverAspectRatio ?? props.book.coverAspectRatio ?? injectedCoverAspectRatio.value)
|
||||||
const { coverUrl } = useCoverVersions()
|
const { coverUrl } = useCoverVersions()
|
||||||
const { seriesCardCoverMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, cardOverlays } = useDisplaySettings()
|
const { seriesCardCoverMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, cardOverlays } = useDisplaySettings()
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ const collapsed = computed(() => props.book.collapsedSeries!)
|
|||||||
const seriesName = computed(() => props.book.seriesName ?? '')
|
const seriesName = computed(() => props.book.seriesName ?? '')
|
||||||
const authorLine = computed(() => props.book.authors.join(', ') || null)
|
const authorLine = computed(() => props.book.authors.join(', ') || null)
|
||||||
const authorQuery = computed(() => props.book.authors[0] ?? 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 showHoverText = computed(() => cardInfoMode.value === 'hover-overlay')
|
||||||
const overlayBackground = computed(() => (cardInfoMode.value === 'hover-overlay' ? 'bg-black/70' : 'bg-black/20'))
|
const overlayBackground = computed(() => (cardInfoMode.value === 'hover-overlay' ? 'bg-black/70' : 'bg-black/20'))
|
||||||
const showProgressBar = computed(() => cardOverlays.value.includes('progress-bar'))
|
const showProgressBar = computed(() => cardOverlays.value.includes('progress-bar'))
|
||||||
@@ -172,7 +174,7 @@ const stackCoverStyles = computed(() =>
|
|||||||
right: `${STACK_COVER_STEP_PCT * index}%`,
|
right: `${STACK_COVER_STEP_PCT * index}%`,
|
||||||
bottom: `${STACK_COVER_STEP_PCT * index}%`,
|
bottom: `${STACK_COVER_STEP_PCT * index}%`,
|
||||||
width: `${coverWidth}%`,
|
width: `${coverWidth}%`,
|
||||||
aspectRatio: coverAspectRatio.value,
|
aspectRatio: effectiveCoverAspectRatio.value,
|
||||||
zIndex: 100 - index,
|
zIndex: 100 - index,
|
||||||
boxShadow:
|
boxShadow:
|
||||||
index === 0
|
index === 0
|
||||||
@@ -211,7 +213,7 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
|
|||||||
interactive
|
interactive
|
||||||
:disable-spine="isAudiobook"
|
:disable-spine="isAudiobook"
|
||||||
:is-comic="isComic"
|
: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 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" />
|
<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 { nextTick, ref } from 'vue'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import type { BookCard } from '@bookorbit/types'
|
import type { BookCard } from '@bookorbit/types'
|
||||||
|
import { COVER_ASPECT_RATIO_KEY } from '../lib/cover-aspect-ratio'
|
||||||
import VirtualBookGrid from './VirtualBookGrid.vue'
|
import VirtualBookGrid from './VirtualBookGrid.vue'
|
||||||
|
|
||||||
const scrollToItemMock = vi.fn<(index: number, options?: { align?: string }) => void>()
|
const scrollToItemMock = vi.fn<(index: number, options?: { align?: string }) => void>()
|
||||||
@@ -38,9 +39,9 @@ vi.mock('./BookCoverCard.vue', () => ({
|
|||||||
vi.mock('./CollapsedSeriesCard.vue', () => ({
|
vi.mock('./CollapsedSeriesCard.vue', () => ({
|
||||||
default: {
|
default: {
|
||||||
name: 'CollapsedSeriesCard',
|
name: 'CollapsedSeriesCard',
|
||||||
props: ['book', 'showLabel', 'selectionMode'],
|
props: ['book', 'showLabel', 'selectionMode', 'coverAspectRatio'],
|
||||||
template:
|
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')
|
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) => {
|
it.each([false, true])('forwards selection mode to collapsed series cards when virtualized=%s', (virtualized) => {
|
||||||
const seriesBook = makeBook(4, {
|
const seriesBook = makeBook(4, {
|
||||||
collapsedSeries: {
|
collapsedSeries: {
|
||||||
|
|||||||
@@ -328,15 +328,18 @@ defineExpose({ scrollToIndex })
|
|||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<div class="book-grid-cell" :class="{ 'book-grid-cell--new': props.newBookIds.has(item.id) }">
|
<div class="book-grid-cell" :class="{ 'book-grid-cell--new': props.newBookIds.has(item.id) }">
|
||||||
<BookCoverSkeleton v-if="isBookPlaceholder(item)" />
|
<BookCoverSkeleton v-if="isBookPlaceholder(item)" />
|
||||||
|
<!-- Every tile takes the grid's shape; art of the other shape follows the cover display setting. -->
|
||||||
<CollapsedSeriesCard
|
<CollapsedSeriesCard
|
||||||
v-else-if="asBook(item).collapsedSeries"
|
v-else-if="asBook(item).collapsedSeries"
|
||||||
:book="asBook(item)"
|
:book="asBook(item)"
|
||||||
|
:cover-aspect-ratio="coverAspectRatio"
|
||||||
:show-label="showLabel"
|
:show-label="showLabel"
|
||||||
:selection-mode="selectionMode"
|
:selection-mode="selectionMode"
|
||||||
/>
|
/>
|
||||||
<BookCoverCard
|
<BookCoverCard
|
||||||
v-else
|
v-else
|
||||||
:book="asBook(item)"
|
:book="asBook(item)"
|
||||||
|
:cover-aspect-ratio="coverAspectRatio"
|
||||||
:show-label="showLabel"
|
:show-label="showLabel"
|
||||||
:selection-mode="selectionMode"
|
:selection-mode="selectionMode"
|
||||||
:selected="isSelected?.(item.id) ?? false"
|
: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 {
|
function slot(updatedAt: string): BookCoverSlot {
|
||||||
return { source: 'extracted', updatedAt, width: null, height: null }
|
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')
|
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 () => {
|
it('moves between the slots with the arrow keys', async () => {
|
||||||
mountLightbox()
|
mountLightbox()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface CarouselBook {
|
|||||||
title: string | null
|
title: string | null
|
||||||
coverAspectRatio: CoverAspectRatio
|
coverAspectRatio: CoverAspectRatio
|
||||||
updatedAt?: string | null
|
updatedAt?: string | null
|
||||||
|
coverVersion?: string
|
||||||
seriesIndex?: string | null
|
seriesIndex?: string | null
|
||||||
hasCover: boolean
|
hasCover: boolean
|
||||||
authors: string[]
|
authors: string[]
|
||||||
@@ -157,7 +158,7 @@ defineExpose({ scroll })
|
|||||||
:style="{ aspectRatio: cardAspectRatio(book) }"
|
:style="{ aspectRatio: cardAspectRatio(book) }"
|
||||||
>
|
>
|
||||||
<BookCoverArtwork
|
<BookCoverArtwork
|
||||||
:src="coverUrl(book.id, 'thumbnail', book.updatedAt)"
|
:src="coverUrl(book.id, 'thumbnail', book.coverVersion ?? book.updatedAt)"
|
||||||
:has-cover="book.hasCover"
|
:has-cover="book.hasCover"
|
||||||
:title="book.title"
|
:title="book.title"
|
||||||
:author-line="book.authors.length > 0 ? book.authors.join(', ') : null"
|
:author-line="book.authors.length > 0 ? book.authors.join(', ') : null"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
import { Image, ImagePlus, Link, Loader2, Lock, LockOpen, Maximize2, RotateCcw, Search, Upload } from '@lucide/vue'
|
import { Image, ImagePlus, Link, Loader2, Lock, LockOpen, Maximize2, RotateCcw, Search, Upload } from '@lucide/vue'
|
||||||
import type { BookDetail, BookMetadataLockField, CoverMedium } from '@bookorbit/types'
|
import type { BookDetail, BookMetadataLockField, CoverMedium } from '@bookorbit/types'
|
||||||
@@ -7,7 +7,7 @@ import { hideOnError } from '../../../lib/metadata-fetch'
|
|||||||
import { useCoverEditor } from '../../../composables/useCoverEditor'
|
import { useCoverEditor } from '../../../composables/useCoverEditor'
|
||||||
import { useCoverVersions } from '../../../composables/useCoverVersions'
|
import { useCoverVersions } from '../../../composables/useCoverVersions'
|
||||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
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 { coverFieldMedium, coverLockField, coverTileState, editorTiles } from '../../../lib/cover-slots'
|
||||||
import { nextRovingIndex } from '@/lib/roving-index'
|
import { nextRovingIndex } from '@/lib/roving-index'
|
||||||
import BookCoverLightbox from '@/features/book/components/BookCoverLightbox.vue'
|
import BookCoverLightbox from '@/features/book/components/BookCoverLightbox.vue'
|
||||||
@@ -23,7 +23,7 @@ const emit = defineEmits<{ coverChanged: [medium: CoverMedium | null]; toggleLoc
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { coverUrl } = useCoverVersions()
|
const { coverUrl } = useCoverVersions()
|
||||||
const { hasPermission } = usePermissions()
|
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)
|
const bookIdRef = computed(() => props.book.id)
|
||||||
// One editor per slot, so a pending image on one tile survives selecting the other.
|
// One editor per slot, so a pending image on one tile survives selecting the other.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
import { formatDate as formatLocaleDate } from '@/i18n/formatters'
|
import { formatDate as formatLocaleDate } from '@/i18n/formatters'
|
||||||
import { formatBytes as formatFileSize } from '@/lib/formatting'
|
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 { readingDateToDateKey } from '@/features/book/lib/reading-date'
|
||||||
import { getProviderColor, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
|
import { getProviderColor, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
|
||||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
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 { FORMAT_TO_GROUP, getPrimaryBookFile, READER_OPENABLE_FORMATS } from '@bookorbit/types'
|
||||||
import type { BookDetail, BookKoboState, CustomMetadataBookValue, ReadAloudProgressSync, ReadStatus, UserBookStatus } 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'
|
import { STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS, useBookStatus } from '@/features/book/composables/useBookStatus'
|
||||||
@@ -341,7 +341,7 @@ watch([coverColumnEl, coverActionsEl], ([column, actions]) => {
|
|||||||
measureCoverLayout()
|
measureCoverLayout()
|
||||||
})
|
})
|
||||||
|
|
||||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
const coverAspectRatio = useBookCoverAspectRatio(() => props.book)
|
||||||
const { bookCoverDisplayMode } = useDisplaySettings()
|
const { bookCoverDisplayMode } = useDisplaySettings()
|
||||||
const detailCoverAspectRatio = computed(() => {
|
const detailCoverAspectRatio = computed(() => {
|
||||||
if (bookCoverDisplayMode.value !== 'natural-bottom' || !hasCover.value || !coverLoaded.value || coverFailed.value || !coverImageRatio.value) {
|
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 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 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')
|
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(() => {
|
const isFaceAudio = computed(() => {
|
||||||
if (!props.book.covers.ebook && !props.book.covers.audio) return isPrimaryAudio.value
|
if (!props.book.covers.ebook && !props.book.covers.audio) return isPrimaryAudio.value
|
||||||
return faceMedium(props.book, coverAspectRatio.value) === 'audio'
|
return faceMedium(props.book, coverAspectRatio.value) === 'audio'
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ArrowLeft, Loader2, Pencil, X } from '@lucide/vue'
|
import { ArrowLeft, Loader2, Pencil, X } from '@lucide/vue'
|
||||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||||
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
|
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
|
||||||
import type { BookCard } from '@bookorbit/types'
|
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 { useBookDetail } from '../../composables/useBookDetail'
|
||||||
import { useMetadataLocks } from '../../composables/useMetadataLocks'
|
import { useMetadataLocks } from '../../composables/useMetadataLocks'
|
||||||
import { useCoverVersions } from '../../composables/useCoverVersions'
|
import { useCoverVersions } from '../../composables/useCoverVersions'
|
||||||
@@ -31,10 +31,10 @@ const mode = ref<'view' | 'edit'>('view')
|
|||||||
const { detail, loading, fetch } = useBookDetail()
|
const { detail, loading, fetch } = useBookDetail()
|
||||||
const { lockedFields, toggle, load: loadLocks } = useMetadataLocks()
|
const { lockedFields, toggle, load: loadLocks } = useMetadataLocks()
|
||||||
const { coverUrl } = useCoverVersions()
|
const { coverUrl } = useCoverVersions()
|
||||||
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
|
|
||||||
const editor = ref<InstanceType<typeof CoverEditorPanel> | null>(null)
|
const editor = ref<InstanceType<typeof CoverEditorPanel> | null>(null)
|
||||||
|
|
||||||
const currentDetail = computed(() => (detail.value && detail.value.id === props.book?.id ? detail.value : 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 hasCoverResolved = computed(() => (currentDetail.value ? currentDetail.value.coverSource !== null : (props.book?.hasCover ?? false)))
|
||||||
const coverVersion = computed(() => currentDetail.value?.coverVersion ?? props.book?.coverVersion ?? null)
|
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)
|
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', () => {
|
it('maps authors from detail.authors[].name array', () => {
|
||||||
const book = makeBook()
|
const book = makeBook()
|
||||||
const detail = makeDetail({
|
const detail = makeDetail({
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function mergeBookCardWithDetail(book: BookCard, detail: BookDetail): Boo
|
|||||||
readStatus: detail.readStatus,
|
readStatus: detail.readStatus,
|
||||||
metadataScore: detail.metadataScore,
|
metadataScore: detail.metadataScore,
|
||||||
hasCover: detail.coverSource !== null,
|
hasCover: detail.coverSource !== null,
|
||||||
|
coverAspectRatio: detail.coverAspectRatio ?? book.coverAspectRatio,
|
||||||
coverVersion: detail.coverVersion,
|
coverVersion: detail.coverVersion,
|
||||||
hasMetadataLocks: detail.lockedFields.length > 0,
|
hasMetadataLocks: detail.lockedFields.length > 0,
|
||||||
lockedFields: detail.lockedFields,
|
lockedFields: detail.lockedFields,
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ export function coverLockField(medium: CoverMedium | null): Extract<BookMetadata
|
|||||||
return medium === 'audio' ? 'audioCover' : 'cover'
|
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 {
|
export function faceMedium(book: Pick<SlotBook, 'covers'>, aspectRatio: CoverAspectRatio | string): CoverMedium {
|
||||||
const preferred: CoverMedium = aspectRatio === '1/1' ? 'audio' : 'ebook'
|
const preferred: CoverMedium = aspectRatio === '1/1' ? 'audio' : 'ebook'
|
||||||
if (book.covers[preferred]) return preferred
|
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[] }[]>(() => [
|
const coverOptions = computed<{ value: CoverAspectRatio; title: string; hint: string; tiles: string[] }[]>(() => [
|
||||||
{
|
{
|
||||||
value: '2/3',
|
value: '2/3',
|
||||||
title: t('library.creator.details.cover.portraitTitle'),
|
title: t('library.creator.details.gridShape.tallTitle'),
|
||||||
hint: t('library.creator.details.cover.portraitHint'),
|
hint: t('library.creator.details.gridShape.tallHint'),
|
||||||
tiles: ['var(--format-ebook)', 'var(--format-document)', 'var(--format-audio)'],
|
tiles: ['var(--format-ebook)', 'var(--format-document)', 'var(--format-audio)'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: '1/1',
|
value: '1/1',
|
||||||
title: t('library.creator.details.cover.squareTitle'),
|
title: t('library.creator.details.gridShape.squareTitle'),
|
||||||
hint: t('library.creator.details.cover.squareHint'),
|
hint: t('library.creator.details.gridShape.squareHint'),
|
||||||
tiles: ['var(--format-kindle)', 'var(--format-comic)', 'var(--format-audio)'],
|
tiles: ['var(--format-kindle)', 'var(--format-comic)', 'var(--format-audio)'],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -193,9 +193,9 @@ function selectPodcastsType() {
|
|||||||
</div>
|
</div>
|
||||||
</LibraryCreatorCard>
|
</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">
|
<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">
|
<div class="grid gap-2.5 @md:grid-cols-2">
|
||||||
<label
|
<label
|
||||||
v-for="option in coverOptions"
|
v-for="option in coverOptions"
|
||||||
@@ -231,7 +231,7 @@ function selectPodcastsType() {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</fieldset>
|
||||||
</LibraryCreatorCard>
|
</LibraryCreatorCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -214,9 +214,9 @@ async function copyPath(folderId: number, path: string) {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 border-t border-border py-1.5">
|
<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">
|
<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>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
@@ -1216,6 +1216,11 @@
|
|||||||
"portrait": "2:3 portrait",
|
"portrait": "2:3 portrait",
|
||||||
"square": "1:1 square"
|
"square": "1:1 square"
|
||||||
},
|
},
|
||||||
|
"gridShape": "Grid shape",
|
||||||
|
"gridShapeValue": {
|
||||||
|
"tall": "Tall cells",
|
||||||
|
"square": "Square cells"
|
||||||
|
},
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"allSupported": "All supported",
|
"allSupported": "All supported",
|
||||||
"patternCount": "{count, plural, one {# pattern} other {# patterns}}",
|
"patternCount": "{count, plural, one {# pattern} other {# patterns}}",
|
||||||
@@ -8817,6 +8822,14 @@
|
|||||||
"squareTitle": "Square 1:1",
|
"squareTitle": "Square 1:1",
|
||||||
"squareHint": "Audiobook-only libraries",
|
"squareHint": "Audiobook-only libraries",
|
||||||
"slotsHint": "A book with both an ebook and an audiobook shows the cover that fits this shape."
|
"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": {
|
"folders": {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const { libraries } = useLibraries()
|
|||||||
provide(
|
provide(
|
||||||
COVER_ASPECT_RATIO_KEY,
|
COVER_ASPECT_RATIO_KEY,
|
||||||
computed(() => {
|
computed(() => {
|
||||||
|
if (detail.value?.coverAspectRatio) return detail.value.coverAspectRatio
|
||||||
const libraryId = detail.value?.libraryId
|
const libraryId = detail.value?.libraryId
|
||||||
const library = libraryId != null ? libraries.value.find((l) => l.id === libraryId) : null
|
const library = libraryId != null ? libraries.value.find((l) => l.id === libraryId) : null
|
||||||
return library?.coverAspectRatio ?? DEFAULT_COVER_ASPECT_RATIO
|
return library?.coverAspectRatio ?? DEFAULT_COVER_ASPECT_RATIO
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ export type BookSeriesMembership = {
|
|||||||
export type BookCard = {
|
export type BookCard = {
|
||||||
id: number;
|
id: number;
|
||||||
status: string;
|
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;
|
coverAspectRatio: CoverAspectRatio;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
authors: string[];
|
authors: string[];
|
||||||
@@ -315,6 +316,8 @@ export type BookDetail = {
|
|||||||
coverSource: "extracted" | "custom" | null;
|
coverSource: "extracted" | "custom" | null;
|
||||||
coverMedia: CoverMedium[];
|
coverMedia: CoverMedium[];
|
||||||
covers: Record<CoverMedium, BookCoverSlot | null>;
|
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;
|
coverVersion: string;
|
||||||
hardcoverEditionId: string | null;
|
hardcoverEditionId: string | null;
|
||||||
providerIds: ProviderIds;
|
providerIds: ProviderIds;
|
||||||
@@ -437,6 +440,8 @@ export type BookRecommendation = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
coverAspectRatio: CoverAspectRatio;
|
coverAspectRatio: CoverAspectRatio;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
|
/** The book card's cover cache key. Absent from servers that predate it, where `updatedAt` serves. */
|
||||||
|
coverVersion?: string;
|
||||||
hasCover: boolean;
|
hasCover: boolean;
|
||||||
authors: string[];
|
authors: string[];
|
||||||
readStatus: UserBookStatus | null;
|
readStatus: UserBookStatus | null;
|
||||||
@@ -452,6 +457,8 @@ export type SeriesBookRecommendation = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
coverAspectRatio: CoverAspectRatio;
|
coverAspectRatio: CoverAspectRatio;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
|
/** The book card's cover cache key. Absent from servers that predate it, where `updatedAt` serves. */
|
||||||
|
coverVersion?: string;
|
||||||
seriesIndex: SeriesIndex | null;
|
seriesIndex: SeriesIndex | null;
|
||||||
hasCover: boolean;
|
hasCover: boolean;
|
||||||
authors: string[];
|
authors: string[];
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ describe('AuthorsService', () => {
|
|||||||
enrichmentExecutor as any,
|
enrichmentExecutor as any,
|
||||||
enrichmentOrchestrator as any,
|
enrichmentOrchestrator as any,
|
||||||
metadataScoreService 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.findAll.mockResolvedValue([{ id: 1 }, { id: 2 }]);
|
||||||
libraryService.findAccessibleLibraryIds.mockResolvedValue([1, 2]);
|
libraryService.findAccessibleLibraryIds.mockResolvedValue([1, 2]);
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export class AuthorsService {
|
|||||||
cardData.tagRows,
|
cardData.tagRows,
|
||||||
cardData.seriesMembershipRows,
|
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));
|
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 };
|
return { items, total: page.total, bookTotal: page.total, page: page.page, size: page.size };
|
||||||
@@ -221,7 +221,7 @@ export class AuthorsService {
|
|||||||
collapsed.tagRows,
|
collapsed.tagRows,
|
||||||
collapsed.seriesMembershipRows,
|
collapsed.seriesMembershipRows,
|
||||||
);
|
);
|
||||||
await this.coverStore.enrichCardVersions(items);
|
await this.coverStore.enrichCardFaces(items);
|
||||||
|
|
||||||
return { items, total: collapsed.total, bookTotal: collapsed.bookTotal, page, size };
|
return { items, total: collapsed.total, bookTotal: collapsed.bookTotal, page, size };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,14 @@ export type BookCoverContext = {
|
|||||||
slots: BookCoverSlotRow[];
|
slots: BookCoverSlotRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BookCoverFaceRow = {
|
||||||
|
bookId: number;
|
||||||
|
libraryCoverAspectRatio: string;
|
||||||
|
primaryFormat: string | null;
|
||||||
|
media: CoverMedia;
|
||||||
|
legacyVersion: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type BookCoverSourceFile = {
|
export type BookCoverSourceFile = {
|
||||||
id: number;
|
id: number;
|
||||||
absolutePath: string;
|
absolutePath: string;
|
||||||
@@ -205,6 +213,37 @@ export class BookCoverStoreRepository {
|
|||||||
return result;
|
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(
|
async applySlotMutation(
|
||||||
bookId: number,
|
bookId: number,
|
||||||
medium: CoverMedium,
|
medium: CoverMedium,
|
||||||
|
|||||||
@@ -77,26 +77,98 @@ function makeLegacyStore(root: string, legacy: BookCoverContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('BookCoverStore', () => {
|
describe('BookCoverStore', () => {
|
||||||
it('versions the face slot together with its fallback slot', () => {
|
describe('faceOf', () => {
|
||||||
const { store } = makeStore(join(tmpdir(), 'unused-cover-store'), context());
|
const { store } = makeStore(join(tmpdir(), 'unused-cover-store'), context());
|
||||||
const ebook = slot('ebook');
|
const ebook = slot('ebook');
|
||||||
const audio = { ...slot('audio'), updatedAt: new Date('2026-02-01T00:00:00.000Z') };
|
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');
|
it("follows the primary file's medium in either library shape", () => {
|
||||||
expect(store.coverVersion('1/1', [ebook, audio], 'unused')).toBe('audio:2026-02-01T00:00:00.000Z:2026-01-01T00:00:00.000Z');
|
expect(face({ primaryFormat: 'm4b' })).toEqual({
|
||||||
expect(store.coverVersion('2/3', [{ ...ebook, dormantSince: new Date('2026-03-01T00:00:00.000Z') }, audio], 'unused')).toBe(
|
coverAspectRatio: '1/1',
|
||||||
'ebook:-:2026-02-01T00:00:00.000Z',
|
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-'));
|
const root = await mkdtemp(join(tmpdir(), 'cover-store-resolve-'));
|
||||||
try {
|
try {
|
||||||
const ebookDir = bookCoverSlotDirPath(root, 7, 'ebook');
|
const ebookDir = bookCoverSlotDirPath(root, 7, 'ebook');
|
||||||
const audioDir = bookCoverSlotDirPath(root, 7, 'audio');
|
const audioDir = bookCoverSlotDirPath(root, 7, 'audio');
|
||||||
await Promise.all([mkdir(ebookDir, { recursive: true }), mkdir(audioDir, { recursive: true })]);
|
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')]);
|
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, { 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'));
|
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');
|
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 () => {
|
it("writes image bytes by shape, and a write with no image to the face slot of the primary file's medium", async () => {
|
||||||
const portrait = makeStore(join(tmpdir(), 'unused-cover-store'), context());
|
const ebookFirst = makeStore(join(tmpdir(), 'unused-cover-store'), context({ coverAspectRatio: '1/1' }));
|
||||||
await expect(portrait.store.chooseWriteMedium(7, await image(80, 80))).resolves.toBe('audio');
|
await expect(ebookFirst.store.chooseWriteMedium(7, await image(80, 80))).resolves.toBe('audio');
|
||||||
await expect(portrait.store.chooseWriteMedium(7)).resolves.toBe('ebook');
|
await expect(ebookFirst.store.chooseWriteMedium(7)).resolves.toBe('ebook');
|
||||||
|
|
||||||
const square = makeStore(join(tmpdir(), 'unused-cover-store'), context({ coverAspectRatio: '1/1' }));
|
const audioFirst = makeStore(join(tmpdir(), 'unused-cover-store'), context({ primaryFileId: 71 }));
|
||||||
await expect(square.store.chooseWriteMedium(7)).resolves.toBe('audio');
|
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 () => {
|
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 { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
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 { randomUUID } from 'crypto';
|
||||||
import { access, copyFile, cp, link, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'fs/promises';
|
import { access, copyFile, cp, link, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'fs/promises';
|
||||||
import { basename, join } from 'path';
|
import { basename, join } from 'path';
|
||||||
@@ -23,7 +31,9 @@ import { BookCoverEventsService } from './book-cover-events.service';
|
|||||||
import { classifyCoverShape } from './cover-shape';
|
import { classifyCoverShape } from './cover-shape';
|
||||||
import {
|
import {
|
||||||
BookCoverStoreRepository,
|
BookCoverStoreRepository,
|
||||||
|
getCoverMediaFromDatabaseFiles,
|
||||||
type BookCoverContext,
|
type BookCoverContext,
|
||||||
|
type BookCoverFaceRow,
|
||||||
type BookCoverOrigin,
|
type BookCoverOrigin,
|
||||||
type BookCoverSlotRow,
|
type BookCoverSlotRow,
|
||||||
type BookCoverSource,
|
type BookCoverSource,
|
||||||
@@ -43,6 +53,13 @@ type SaveOptions = {
|
|||||||
|
|
||||||
type SlotStateOptions = { backfill?: boolean };
|
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.
|
// A grid of books with missing thumbnails would otherwise resize every cover at once.
|
||||||
const THUMBNAIL_REPAIR_CONCURRENCY = 4;
|
const THUMBNAIL_REPAIR_CONCURRENCY = 4;
|
||||||
|
|
||||||
@@ -368,18 +385,7 @@ export class BookCoverStore {
|
|||||||
|
|
||||||
async faceMediumFor(bookId: number): Promise<CoverMedium> {
|
async faceMediumFor(bookId: number): Promise<CoverMedium> {
|
||||||
const context = await this.requireContext(bookId);
|
const context = await this.requireContext(bookId);
|
||||||
const preferred = this.faceMedium(context);
|
return this.servedFace(this.faceMedium(context), context.slots, context.media);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async mediaFor(bookId: number): Promise<CoverMedia> {
|
async mediaFor(bookId: number): Promise<CoverMedia> {
|
||||||
@@ -410,10 +416,55 @@ export class BookCoverStore {
|
|||||||
return this.repository.slotsFor(bookIds);
|
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));
|
const slotsByBookId = await this.repository.slotsFor(cards.map((card) => card.id));
|
||||||
for (const card of cards) {
|
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}`;
|
if (slots.length === 0) return `legacy:${legacyVersion}`;
|
||||||
const face: CoverMedium = coverAspectRatio === '1/1' ? 'audio' : 'ebook';
|
|
||||||
const other: CoverMedium = face === 'ebook' ? 'audio' : 'ebook';
|
const other: CoverMedium = face === 'ebook' ? 'audio' : 'ebook';
|
||||||
const stamp = (medium: CoverMedium) => slots.find((slot) => slot.medium === medium && slot.dormantSince === null)?.updatedAt.toISOString() ?? '-';
|
const stamp = (medium: CoverMedium) => slots.find((slot) => slot.medium === medium && slot.dormantSince === null)?.updatedAt.toISOString() ?? '-';
|
||||||
return `${face}:${stamp(face)}:${stamp(other)}`;
|
return `${face}:${stamp(face)}:${stamp(other)}`;
|
||||||
@@ -616,10 +666,14 @@ export class BookCoverStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private primaryMedium(context: BookCoverContext): CoverMedium {
|
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';
|
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 {
|
private isContent(role: string): boolean {
|
||||||
return role === 'content' || role === 'primary';
|
return role === 'content' || role === 'primary';
|
||||||
}
|
}
|
||||||
@@ -691,8 +745,25 @@ export class BookCoverStore {
|
|||||||
return [face, face === 'ebook' ? 'audio' : 'ebook'];
|
return [face, face === 'ebook' ? 'audio' : 'ebook'];
|
||||||
}
|
}
|
||||||
|
|
||||||
private faceMedium(context: Pick<BookCoverContext, 'coverAspectRatio'>): CoverMedium {
|
private faceMedium(context: BookCoverContext): CoverMedium {
|
||||||
return context.coverAspectRatio === '1/1' ? 'audio' : 'ebook';
|
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 {
|
private mediumIsActive(medium: CoverMedium, media: CoverMedia): boolean {
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ function makeService(overrides: { bookMetadataLockService?: unknown; appDataPath
|
|||||||
const coverStore = {
|
const coverStore = {
|
||||||
resolve: vi.fn().mockResolvedValue(null),
|
resolve: vi.fn().mockResolvedValue(null),
|
||||||
fetchState: vi.fn().mockResolvedValue({ media: { hasEbook: true, hasAudio: false }, filled: { ebook: false, audio: false }, locked: [] }),
|
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),
|
contextFor: vi.fn().mockResolvedValue(null),
|
||||||
slotDtos: vi.fn().mockReturnValue({ ebook: null, audio: null }),
|
slotDtos: vi.fn().mockReturnValue({ ebook: null, audio: null }),
|
||||||
coverVersion: vi.fn((_coverAspectRatio: string, _slots: unknown[], legacyVersion: string) => `legacy:${legacyVersion}`),
|
coverVersion: vi.fn((_coverAspectRatio: string, _slots: unknown[], legacyVersion: string) => `legacy:${legacyVersion}`),
|
||||||
|
|||||||
@@ -1152,7 +1152,7 @@ export class BookService {
|
|||||||
tagRows,
|
tagRows,
|
||||||
seriesMembershipRows,
|
seriesMembershipRows,
|
||||||
);
|
);
|
||||||
await this.coverStore.enrichCardVersions(cards);
|
await this.coverStore.enrichCardFaces(cards);
|
||||||
const result = {
|
const result = {
|
||||||
items: cards,
|
items: cards,
|
||||||
total,
|
total,
|
||||||
@@ -1195,7 +1195,7 @@ export class BookService {
|
|||||||
seriesMembershipRows,
|
seriesMembershipRows,
|
||||||
customMetadataRows,
|
customMetadataRows,
|
||||||
);
|
);
|
||||||
await this.coverStore.enrichCardVersions(cards);
|
await this.coverStore.enrichCardFaces(cards);
|
||||||
const result = {
|
const result = {
|
||||||
items: cards,
|
items: cards,
|
||||||
total,
|
total,
|
||||||
@@ -3268,7 +3268,7 @@ export class BookService {
|
|||||||
this.bookRepo.findReadAloudSyncMode(user.id, id),
|
this.bookRepo.findReadAloudSyncMode(user.id, id),
|
||||||
this.coverStore.contextFor(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 { book, authorRows, genreRows, tagRows, fileRows, narratorRows, seriesMembershipRows, communityRatingRows } = result;
|
||||||
const meta = book.book_metadata;
|
const meta = book.book_metadata;
|
||||||
@@ -3318,13 +3318,9 @@ export class BookService {
|
|||||||
personalNoteUpdatedAt: personalNote ? new Date(personalNote.updatedAt) : null,
|
personalNoteUpdatedAt: personalNote ? new Date(personalNote.updatedAt) : null,
|
||||||
communityRatings: this.mapCommunityRatingRows(communityRatingRows),
|
communityRatings: this.mapCommunityRatingRows(communityRatingRows),
|
||||||
coverSource: (meta?.coverSource as 'extracted' | 'custom' | null) ?? null,
|
coverSource: (meta?.coverSource as 'extracted' | 'custom' | null) ?? null,
|
||||||
coverMedia: [...(coverContext?.media.hasEbook ? (['ebook'] as const) : []), ...(coverContext?.media.hasAudio ? (['audio'] as const) : [])],
|
coverMedia: [...(coverContext.media.hasEbook ? (['ebook'] as const) : []), ...(coverContext.media.hasAudio ? (['audio'] as const) : [])],
|
||||||
covers: coverContext ? this.coverStore.slotDtos(coverContext) : { ebook: null, audio: null },
|
covers: this.coverStore.slotDtos(coverContext),
|
||||||
coverVersion: this.coverStore.coverVersion(
|
...this.coverStore.faceForContext(coverContext, (meta?.coverUpdatedAt ?? book.books.updatedAt ?? book.books.addedAt).toISOString()),
|
||||||
book.libraries?.coverAspectRatio ?? '2/3',
|
|
||||||
coverContext?.slots ?? [],
|
|
||||||
(meta?.coverUpdatedAt ?? book.books.updatedAt ?? book.books.addedAt).toISOString(),
|
|
||||||
),
|
|
||||||
hardcoverEditionId: meta?.hardcoverEditionId ?? null,
|
hardcoverEditionId: meta?.hardcoverEditionId ?? null,
|
||||||
lockedFields: this.bookMetadataLockService.normalizeLockedFields(meta?.lockedFields),
|
lockedFields: this.bookMetadataLockService.normalizeLockedFields(meta?.lockedFields),
|
||||||
providerIds: {
|
providerIds: {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
CustomMetadataBookValue,
|
CustomMetadataBookValue,
|
||||||
ComicMetadataFields,
|
ComicMetadataFields,
|
||||||
BookCoverSlot,
|
BookCoverSlot,
|
||||||
|
CoverAspectRatio,
|
||||||
CoverMedium,
|
CoverMedium,
|
||||||
NarratorRef,
|
NarratorRef,
|
||||||
ProviderIds,
|
ProviderIds,
|
||||||
@@ -63,6 +64,8 @@ export class BookDetailDto {
|
|||||||
coverSource: 'extracted' | 'custom' | null;
|
coverSource: 'extracted' | 'custom' | null;
|
||||||
coverMedia: CoverMedium[];
|
coverMedia: CoverMedium[];
|
||||||
covers: Record<CoverMedium, BookCoverSlot | null>;
|
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;
|
coverVersion: string;
|
||||||
hardcoverEditionId: string | null;
|
hardcoverEditionId: string | null;
|
||||||
providerIds: ProviderIds;
|
providerIds: ProviderIds;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function makeService() {
|
|||||||
findOne: vi.fn().mockResolvedValue({ mediaType: 'books' }),
|
findOne: vi.fn().mockResolvedValue({ mediaType: 'books' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const coverStore = { enrichCardVersions: vi.fn().mockResolvedValue(undefined) };
|
const coverStore = { enrichCardFaces: vi.fn().mockResolvedValue(undefined) };
|
||||||
const service = new DashboardService(
|
const service = new DashboardService(
|
||||||
dashboardRepo as never,
|
dashboardRepo as never,
|
||||||
bookReadService as never,
|
bookReadService as never,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export class DashboardService {
|
|||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
const cards = assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows, narratorRows, tagRows);
|
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]));
|
const cardsById = new Map(cards.map((card) => [card.id, card]));
|
||||||
return bookIds.map((id) => cardsById.get(id)).filter((card): card is BookCard => card != null);
|
return bookIds.map((id) => cardsById.get(id)).filter((card): card is BookCard => card != null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,15 +158,13 @@ describe('toNotebookBookLabel', () => {
|
|||||||
title: 'Dune',
|
title: 'Dune',
|
||||||
author: 'Frank Herbert, Brian Herbert',
|
author: 'Frank Herbert, Brian Herbert',
|
||||||
coverSource: 'extracted',
|
coverSource: 'extracted',
|
||||||
coverAspectRatio: '1/1',
|
|
||||||
updatedAt: updated,
|
|
||||||
readFileId: 77,
|
readFileId: 77,
|
||||||
readFileFormat: 'EPUB',
|
readFileFormat: 'EPUB',
|
||||||
hasAudio: true,
|
hasAudio: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('maps the label with the given cover version', () => {
|
it('maps the label with the given cover face', () => {
|
||||||
expect(toNotebookBookLabel(row, 'audio:x:-')).toEqual({
|
expect(toNotebookBookLabel(row, { coverVersion: 'audio:x:-', coverAspectRatio: '1/1' })).toEqual({
|
||||||
id: 5,
|
id: 5,
|
||||||
title: 'Dune',
|
title: 'Dune',
|
||||||
author: 'Frank Herbert, Brian Herbert',
|
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', () => {
|
it('falls back to a portrait frame without a face and reports a book with nothing to read', () => {
|
||||||
expect(
|
expect(toNotebookBookLabel({ ...row, coverSource: null, readFileId: null, readFileFormat: null, hasAudio: false }, undefined)).toMatchObject({
|
||||||
toNotebookBookLabel(
|
hasCover: false,
|
||||||
{ ...row, coverSource: null, coverAspectRatio: 'odd', readFileId: null, readFileFormat: null, hasAudio: false },
|
coverVersion: null,
|
||||||
'legacy:x',
|
coverAspectRatio: '2/3',
|
||||||
),
|
readFileId: null,
|
||||||
).toMatchObject({ hasCover: false, coverAspectRatio: '2/3', readFileId: null, readFileFormat: null, hasAudio: false });
|
readFileFormat: null,
|
||||||
|
hasAudio: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
normalizeCoverAspectRatio,
|
|
||||||
type AnnotationPositionStatus,
|
type AnnotationPositionStatus,
|
||||||
|
type CoverAspectRatio,
|
||||||
type NotebookBookLabel,
|
type NotebookBookLabel,
|
||||||
type NotebookBookmarkItem,
|
type NotebookBookmarkItem,
|
||||||
type NotebookHighlightEntry,
|
type NotebookHighlightEntry,
|
||||||
@@ -60,8 +60,6 @@ export interface NotebookLabelRow {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
author: string | null;
|
author: string | null;
|
||||||
coverSource: string | null;
|
coverSource: string | null;
|
||||||
coverAspectRatio: string;
|
|
||||||
updatedAt: Date;
|
|
||||||
readFileId: number | null;
|
readFileId: number | null;
|
||||||
readFileFormat: string | null;
|
readFileFormat: string | null;
|
||||||
hasAudio: boolean;
|
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. */
|
/** The face is computed by the cover store, exactly as it is for a library book card. */
|
||||||
export function toNotebookBookLabel(row: NotebookLabelRow, coverVersion: string): NotebookBookLabel {
|
export function toNotebookBookLabel(
|
||||||
|
row: NotebookLabelRow,
|
||||||
|
face: { coverVersion: string; coverAspectRatio: CoverAspectRatio } | undefined,
|
||||||
|
): NotebookBookLabel {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title ?? null,
|
title: row.title ?? null,
|
||||||
author: row.author ?? null,
|
author: row.author ?? null,
|
||||||
hasCover: row.coverSource != null,
|
hasCover: row.coverSource != null,
|
||||||
coverVersion,
|
coverVersion: face?.coverVersion ?? null,
|
||||||
coverAspectRatio: normalizeCoverAspectRatio(row.coverAspectRatio),
|
coverAspectRatio: face?.coverAspectRatio ?? '2/3',
|
||||||
readFileId: row.readFileId ?? null,
|
readFileId: row.readFileId ?? null,
|
||||||
readFileFormat: row.readFileFormat ? row.readFileFormat.toLowerCase() : null,
|
readFileFormat: row.readFileFormat ? row.readFileFormat.toLowerCase() : null,
|
||||||
hasAudio: row.hasAudio === true,
|
hasAudio: row.hasAudio === true,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
bookMetadata,
|
bookMetadata,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
books,
|
books,
|
||||||
libraries,
|
|
||||||
userBookNotes,
|
userBookNotes,
|
||||||
userBookRatings,
|
userBookRatings,
|
||||||
} from '../../db/schema';
|
} from '../../db/schema';
|
||||||
@@ -335,8 +334,6 @@ export class NotebookRepository {
|
|||||||
where ${bookAuthors.bookId} = ${books.id}
|
where ${bookAuthors.bookId} = ${books.id}
|
||||||
)`,
|
)`,
|
||||||
coverSource: bookMetadata.coverSource,
|
coverSource: bookMetadata.coverSource,
|
||||||
coverAspectRatio: libraries.coverAspectRatio,
|
|
||||||
updatedAt: books.updatedAt,
|
|
||||||
readFileId: readFileSql<number | null>(sql`${bookFiles.id}`),
|
readFileId: readFileSql<number | null>(sql`${bookFiles.id}`),
|
||||||
readFileFormat: readFileSql<string | null>(sql`lower(${bookFiles.format})`),
|
readFileFormat: readFileSql<string | null>(sql`lower(${bookFiles.format})`),
|
||||||
hasAudio: sql<boolean>`exists (
|
hasAudio: sql<boolean>`exists (
|
||||||
@@ -347,7 +344,6 @@ export class NotebookRepository {
|
|||||||
)`,
|
)`,
|
||||||
})
|
})
|
||||||
.from(books)
|
.from(books)
|
||||||
.innerJoin(libraries, eq(libraries.id, books.libraryId))
|
|
||||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||||
.where(inArray(books.id, [...bookIds]));
|
.where(inArray(books.id, [...bookIds]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,6 @@ function labelRow(id: number) {
|
|||||||
title: `Book ${id}`,
|
title: `Book ${id}`,
|
||||||
author: 'Author',
|
author: 'Author',
|
||||||
coverSource: 'extracted',
|
coverSource: 'extracted',
|
||||||
coverAspectRatio: '2/3',
|
|
||||||
updatedAt: at,
|
|
||||||
readFileId: id * 10,
|
readFileId: id * 10,
|
||||||
readFileFormat: 'epub',
|
readFileFormat: 'epub',
|
||||||
hasAudio: false,
|
hasAudio: false,
|
||||||
@@ -88,8 +86,9 @@ function makeService(libraryIds = [1, 2]) {
|
|||||||
};
|
};
|
||||||
const libraryService = { findAccessibleLibraryIds: vi.fn().mockResolvedValue(libraryIds) };
|
const libraryService = { findAccessibleLibraryIds: vi.fn().mockResolvedValue(libraryIds) };
|
||||||
const coverStore = {
|
const coverStore = {
|
||||||
slotsFor: vi.fn().mockResolvedValue(new Map()),
|
facesFor: vi.fn((ids: number[]) =>
|
||||||
coverVersion: vi.fn((ratio: string, _slots: unknown[], legacy: string) => `legacy:${ratio}:${legacy}`),
|
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);
|
const service = new NotebookService(repo as never, libraryService as never, coverStore as never);
|
||||||
return { service, repo, libraryService, coverStore };
|
return { service, repo, libraryService, coverStore };
|
||||||
@@ -152,7 +151,13 @@ describe('NotebookService.entries', () => {
|
|||||||
]);
|
]);
|
||||||
expect(repo.findJournal).toHaveBeenCalledWith(8, []);
|
expect(repo.findJournal).toHaveBeenCalledWith(8, []);
|
||||||
expect(Object.keys(result.books).sort()).toEqual(['5', '6']);
|
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 });
|
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_PAGE_SIZE_DEFAULT,
|
||||||
NOTEBOOK_REVIEW_DAILY_SIZE,
|
NOTEBOOK_REVIEW_DAILY_SIZE,
|
||||||
NOTEBOOK_REVIEW_SHUFFLE_MAX,
|
NOTEBOOK_REVIEW_SHUFFLE_MAX,
|
||||||
normalizeCoverAspectRatio,
|
|
||||||
type AnnotationItem,
|
type AnnotationItem,
|
||||||
type NotebookBookLabel,
|
type NotebookBookLabel,
|
||||||
type NotebookBookRow,
|
type NotebookBookRow,
|
||||||
@@ -261,20 +260,13 @@ export class NotebookService {
|
|||||||
return entries;
|
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>> {
|
private async labels(bookIds: readonly number[]): Promise<Record<string, NotebookBookLabel>> {
|
||||||
const ids = [...new Set(bookIds)];
|
const ids = [...new Set(bookIds)];
|
||||||
if (ids.length === 0) return {};
|
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> = {};
|
const labels: Record<string, NotebookBookLabel> = {};
|
||||||
for (const row of rows) {
|
for (const row of rows) labels[String(row.id)] = toNotebookBookLabel(row, faces.get(row.id));
|
||||||
const coverVersion = this.coverStore.coverVersion(
|
|
||||||
normalizeCoverAspectRatio(row.coverAspectRatio),
|
|
||||||
slotsByBook.get(row.id) ?? [],
|
|
||||||
row.updatedAt.toISOString(),
|
|
||||||
);
|
|
||||||
labels[String(row.id)] = toNotebookBookLabel(row, coverVersion);
|
|
||||||
}
|
|
||||||
return labels;
|
return labels;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { BookModule } from '../book/book.module';
|
import { BookModule } from '../book/book.module';
|
||||||
|
import { BookCoverStoreModule } from '../book-cover-store/book-cover-store.module';
|
||||||
import { EmbeddingModule } from '../embedding/embedding.module';
|
import { EmbeddingModule } from '../embedding/embedding.module';
|
||||||
import { LibraryModule } from '../library/library.module';
|
import { LibraryModule } from '../library/library.module';
|
||||||
import { UserBookStatusModule } from '../user-book-status/user-book-status.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';
|
import { RecommendationService } from './recommendation.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BookModule, LibraryModule, EmbeddingModule, UserBookStatusModule],
|
imports: [BookModule, BookCoverStoreModule, LibraryModule, EmbeddingModule, UserBookStatusModule],
|
||||||
controllers: [RecommendationController],
|
controllers: [RecommendationController],
|
||||||
providers: [RecommendationService, RecommendationRepository],
|
providers: [RecommendationService, RecommendationRepository],
|
||||||
exports: [RecommendationService],
|
exports: [RecommendationService],
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ function makeService() {
|
|||||||
const userBookStatusService = {
|
const userBookStatusService = {
|
||||||
findByBookIds: vi.fn().mockResolvedValue(new Map()),
|
findByBookIds: vi.fn().mockResolvedValue(new Map()),
|
||||||
};
|
};
|
||||||
|
const coverStore = {
|
||||||
|
facesFor: vi.fn().mockResolvedValue(new Map()),
|
||||||
|
};
|
||||||
|
|
||||||
const service = new RecommendationService(
|
const service = new RecommendationService(
|
||||||
recRepo as never,
|
recRepo as never,
|
||||||
@@ -54,9 +57,10 @@ function makeService() {
|
|||||||
libraryService as never,
|
libraryService as never,
|
||||||
embedder as never,
|
embedder as never,
|
||||||
userBookStatusService 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', () => {
|
describe('RecommendationService', () => {
|
||||||
@@ -540,6 +544,24 @@ describe('RecommendationService', () => {
|
|||||||
expect(result[1].readStatus).toBeNull();
|
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 () => {
|
it('returns empty array when author has no other books', async () => {
|
||||||
const { service, bookRepo, recRepo, libraryService } = makeService();
|
const { service, bookRepo, recRepo, libraryService } = makeService();
|
||||||
bookRepo.findLibraryIdByBookId.mockResolvedValue(5);
|
bookRepo.findLibraryIdByBookId.mockResolvedValue(5);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
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 { normalizeCoverAspectRatio } from '@bookorbit/types';
|
||||||
import type { RequestUser } from '../../common/types/request-user';
|
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 { BookEmbedderService } from '../embedding/book-embedder.service';
|
||||||
import { BookReadService } from '../book/book-read.service';
|
import { BookReadService } from '../book/book-read.service';
|
||||||
import { LibraryService } from '../library/library.service';
|
import { LibraryService } from '../library/library.service';
|
||||||
@@ -34,14 +35,17 @@ export class RecommendationService {
|
|||||||
private readonly libraryService: LibraryService,
|
private readonly libraryService: LibraryService,
|
||||||
private readonly embedder: BookEmbedderService,
|
private readonly embedder: BookEmbedderService,
|
||||||
private readonly userBookStatusService: UserBookStatusService,
|
private readonly userBookStatusService: UserBookStatusService,
|
||||||
|
private readonly coverStore: BookCoverStore,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async withReadStatus<T extends { id: number }>(rows: T[], userId: number): Promise<(T & { readStatus: UserBookStatus | null })[]> {
|
/** Adds the viewer's read status and each book's cover face, which replaces the library shape the rows carry. */
|
||||||
const statuses = await this.userBookStatusService.findByBookIds(
|
private async withCoverAndReadStatus<T extends { id: number; coverAspectRatio: CoverAspectRatio }>(
|
||||||
userId,
|
rows: T[],
|
||||||
rows.map((row) => row.id),
|
userId: number,
|
||||||
);
|
): Promise<(T & { coverVersion?: string; readStatus: UserBookStatus | null })[]> {
|
||||||
return rows.map((row) => ({ ...row, readStatus: statuses.get(row.id) ?? 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[]> {
|
async getRecommendations(bookId: number, user: RequestUser): Promise<BookRecommendation[]> {
|
||||||
@@ -104,7 +108,7 @@ export class RecommendationService {
|
|||||||
const ordered = rescored
|
const ordered = rescored
|
||||||
.map((rescoredCandidate) => rowMap.get(rescoredCandidate.bookId))
|
.map((rescoredCandidate) => rowMap.get(rescoredCandidate.bookId))
|
||||||
.filter((row): row is UnscopedBookRecommendation => row != null);
|
.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(
|
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`,
|
`[${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`,
|
`[${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) => ({
|
rows.map((r) => ({
|
||||||
id: r.bookId,
|
id: r.bookId,
|
||||||
title: r.title,
|
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`,
|
`[${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) => ({
|
rows.map((r) => ({
|
||||||
id: r.bookId,
|
id: r.bookId,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ const mockMetadata = {
|
|||||||
function makeService(
|
function makeService(
|
||||||
repo: ReturnType<typeof makeRepo>,
|
repo: ReturnType<typeof makeRepo>,
|
||||||
autoFetchOrchestrator?: { scheduleImportedBooksIfEligible: (...args: unknown[]) => Promise<number> },
|
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> },
|
coverReconciler?: { enqueue: (...args: unknown[]) => Promise<void>; enqueueExpiredDormant?: (libraryId: number) => Promise<void> },
|
||||||
) {
|
) {
|
||||||
const jobStore = new ScanJobStore();
|
const jobStore = new ScanJobStore();
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
|||||||
private async buildAndEmitBookCards(libraryId: number, bookIds: number[]): Promise<void> {
|
private async buildAndEmitBookCards(libraryId: number, bookIds: number[]): Promise<void> {
|
||||||
const { rows, authorRows, fileRows, genreRows } = await this.scannerRepo.findBookCardData(bookIds);
|
const { rows, authorRows, fileRows, genreRows } = await this.scannerRepo.findBookCardData(bookIds);
|
||||||
const cards = assembleBookCards(rows, authorRows, fileRows, genreRows, []);
|
const cards = assembleBookCards(rows, authorRows, fileRows, genreRows, []);
|
||||||
await this.coverStore.enrichCardVersions(cards);
|
await this.coverStore.enrichCardFaces(cards);
|
||||||
if (cards.length > 0) {
|
if (cards.length > 0) {
|
||||||
this.scanGateway.emitBooksAdded({ libraryId, books: cards } satisfies ScanBooksAddedEvent);
|
this.scanGateway.emitBooksAdded({ libraryId, books: cards } satisfies ScanBooksAddedEvent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ describe('SeriesService', () => {
|
|||||||
seriesRepo as any,
|
seriesRepo as any,
|
||||||
bookReadService as any,
|
bookReadService as any,
|
||||||
libraryService 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.findAll.mockResolvedValue([{ id: 1 }, { id: 2 }]);
|
||||||
libraryService.findAccessibleLibraryIds.mockResolvedValue([1, 2]);
|
libraryService.findAccessibleLibraryIds.mockResolvedValue([1, 2]);
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export class SeriesService {
|
|||||||
cardData.tagRows,
|
cardData.tagRows,
|
||||||
cardData.seriesMembershipRows,
|
cardData.seriesMembershipRows,
|
||||||
);
|
);
|
||||||
await this.coverStore.enrichCardVersions(cards);
|
await this.coverStore.enrichCardFaces(cards);
|
||||||
const orderMap = new Map(bookPage.bookIds.map((id, i) => [id, i]));
|
const orderMap = new Map(bookPage.bookIds.map((id, i) => [id, i]));
|
||||||
items = cards
|
items = cards
|
||||||
.map((card) => {
|
.map((card) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user