From 82608dbafd9be1e8bc1b5f77035376e203a890e3 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:05:33 +0000 Subject: [PATCH] feat(books): show read-along chapters and hide audiobook request An EPUB with Media Overlays already carries its narration, yet the Chapters tab required audio files and the Editions panel offered to request an audiobook the book already had. List the narration sections of such an EPUB as a read-only chapter list, and count the narration as the audio half when deciding which format is missing. The chapters state gains an optional readAlong flag, so released clients keep working. Editing stays tied to audio files, because an EPUB has no chapter metadata to write. Ref #2032 --- .../BookMissingFormatRequest.spec.ts | 27 ++++++-- .../composables/useMissingFormatRequest.ts | 9 +-- .../components/detail/BookDetailHeader.vue | 4 +- .../components/detail/BookDetailLayout.vue | 6 +- .../book/components/detail/BookDetailTabs.vue | 4 +- .../detail/chapters/ChapterListCard.vue | 4 +- .../detail/chapters/ChaptersHeroBar.vue | 2 + .../__tests__/ChaptersHeroBar.spec.ts | 12 ++++ .../components/detail/tabs/ChaptersTab.vue | 18 ++++- .../lib/__tests__/book-detail-tabs.spec.ts | 36 ++++++++++ .../src/features/book/lib/book-detail-tabs.ts | 10 +++ client/src/locales/en.json | 2 + client/src/views/BookDetailView.vue | 14 ++-- packages/types/src/audiobook.ts | 5 ++ .../chapter-editor.service.test.ts | 54 +++++++++++++++ .../chapter-editor/chapter-editor.service.ts | 66 +++++++++++++++---- 16 files changed, 235 insertions(+), 38 deletions(-) create mode 100644 client/src/features/book/lib/__tests__/book-detail-tabs.spec.ts 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) {