diff --git a/client/src/features/book-requests/__tests__/BookMissingFormatRequest.spec.ts b/client/src/features/book-requests/__tests__/BookMissingFormatRequest.spec.ts index db6b6f9d6..e33301c5d 100644 --- a/client/src/features/book-requests/__tests__/BookMissingFormatRequest.spec.ts +++ b/client/src/features/book-requests/__tests__/BookMissingFormatRequest.spec.ts @@ -20,7 +20,7 @@ vi.mock('../composables/useBookRequestVisibility', () => ({ import BookMissingFormatRequest from '../components/BookMissingFormatRequest.vue' -function book(formats: string[]): BookDetail { +function book(formats: string[], readAlong = false): BookDetail { return { id: 42, title: 'Dune', @@ -30,7 +30,13 @@ function book(formats: string[]): BookDetail { seriesName: null, publishedYear: 1965, language: 'en', - files: formats.map((format, index) => ({ id: index + 1, format, role: 'content', filename: `dune.${format}` })), + files: formats.map((format, index) => ({ + id: index + 1, + format, + role: 'content', + filename: `dune.${format}`, + ...(readAlong && format === 'epub' ? { mediaOverlay: { available: true, durationSeconds: 3600 } } : {}), + })), } as unknown as BookDetail } @@ -40,8 +46,8 @@ function json(body: unknown, ok = true): Response { const NOTHING = { ownedBookId: null, existingRequestId: null, existingRequestStatus: null, alreadySubscribed: false } -async function render(formats: string[]) { - const wrapper = mount(BookMissingFormatRequest, { props: { book: book(formats) }, global: { stubs: { RouterLink: RouterLinkStub } } }) +async function render(formats: string[], readAlong = false) { + const wrapper = mount(BookMissingFormatRequest, { props: { book: book(formats, readAlong) }, global: { stubs: { RouterLink: RouterLinkStub } } }) await flushPromises() return wrapper } @@ -63,6 +69,19 @@ describe('BookMissingFormatRequest', () => { expect(JSON.parse(init!.body as string).items[0]).toMatchObject({ title: 'Dune', mediaKind: 'audiobook', isbn13: '9780441013593' }) }) + it('does not ask for an audiobook when the EPUB already carries its narration', async () => { + const wrapper = await render(['epub'], true) + + expect(wrapper.find('[role="group"]').exists()).toBe(false) + expect(state.api).not.toHaveBeenCalled() + }) + + it('still asks for an audiobook beside a plain EPUB', async () => { + const wrapper = await render(['epub', 'pdf'], false) + + expect(wrapper.get('button').attributes('aria-label')).toBe('Request audiobook') + }) + it('offers the ebook an audiobook is missing', async () => { const wrapper = await render(['m4b']) diff --git a/client/src/features/book-requests/composables/useMissingFormatRequest.ts b/client/src/features/book-requests/composables/useMissingFormatRequest.ts index 66c9ddc2e..897ac72cc 100644 --- a/client/src/features/book-requests/composables/useMissingFormatRequest.ts +++ b/client/src/features/book-requests/composables/useMissingFormatRequest.ts @@ -2,6 +2,7 @@ import { computed, ref, watch, type Ref } from 'vue' import { BOOK_REQUEST_FORMATS_BY_MEDIA_KIND } from '@bookorbit/types' import type { BookDetail, BookRequestAvailability, BookRequestMediaKind, BookRequestSubmitResult, CreateBookRequestPayload } from '@bookorbit/types' import { api } from '@/lib/api' +import { hasReadAlong } from '@/features/book/lib/file-capabilities' import { pairHalfState, type PairHalfState } from '../requestPair' import { submitFailure, type SubmitFailure } from './useRequestSubmission' @@ -23,11 +24,11 @@ export function useMissingFormatRequest(book: Ref, enabled: R const missing = computed(() => { if (!book.value) return null - const formats = book.value.files - .filter((file) => file.role === 'content' || file.role === 'primary') - .map((file) => file.format?.toLowerCase() ?? '') + const files = book.value.files.filter((file) => file.role === 'content' || file.role === 'primary') + const formats = files.map((file) => file.format?.toLowerCase() ?? '') const hasReadable = formats.some((format) => BOOK_REQUEST_FORMATS_BY_MEDIA_KIND.ebook.includes(format)) - const hasAudio = formats.some((format) => BOOK_REQUEST_FORMATS_BY_MEDIA_KIND.audiobook.includes(format)) + // A read-along EPUB carries its narration, so the book already has the audio half. + const hasAudio = formats.some((format) => BOOK_REQUEST_FORMATS_BY_MEDIA_KIND.audiobook.includes(format)) || files.some(hasReadAlong) if (hasReadable && !hasAudio) return 'audiobook' if (hasAudio && !hasReadable) return 'ebook' return null diff --git a/client/src/features/book/components/detail/BookDetailHeader.vue b/client/src/features/book/components/detail/BookDetailHeader.vue index d4f0db3c5..ccc19e085 100644 --- a/client/src/features/book/components/detail/BookDetailHeader.vue +++ b/client/src/features/book/components/detail/BookDetailHeader.vue @@ -6,7 +6,7 @@ import { useI18n } from 'vue-i18n' import BookDetailTabs from './BookDetailTabs.vue' import { useBookNavigation } from '../../composables/useBookNavigation' -const props = defineProps<{ bookId: number; canEditMetadata?: boolean; hasAudio?: boolean; parentLibrary?: { id: number; name: string } | null }>() +const props = defineProps<{ bookId: number; canEditMetadata?: boolean; hasChapters?: boolean; parentLibrary?: { id: number; name: string } | null }>() const { t } = useI18n() const route = useRoute() @@ -48,7 +48,7 @@ async function navigateToBook(id: number) {