mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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
This commit is contained in:
@@ -20,7 +20,7 @@ vi.mock('../composables/useBookRequestVisibility', () => ({
|
|||||||
|
|
||||||
import BookMissingFormatRequest from '../components/BookMissingFormatRequest.vue'
|
import BookMissingFormatRequest from '../components/BookMissingFormatRequest.vue'
|
||||||
|
|
||||||
function book(formats: string[]): BookDetail {
|
function book(formats: string[], readAlong = false): BookDetail {
|
||||||
return {
|
return {
|
||||||
id: 42,
|
id: 42,
|
||||||
title: 'Dune',
|
title: 'Dune',
|
||||||
@@ -30,7 +30,13 @@ function book(formats: string[]): BookDetail {
|
|||||||
seriesName: null,
|
seriesName: null,
|
||||||
publishedYear: 1965,
|
publishedYear: 1965,
|
||||||
language: 'en',
|
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
|
} 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 }
|
const NOTHING = { ownedBookId: null, existingRequestId: null, existingRequestStatus: null, alreadySubscribed: false }
|
||||||
|
|
||||||
async function render(formats: string[]) {
|
async function render(formats: string[], readAlong = false) {
|
||||||
const wrapper = mount(BookMissingFormatRequest, { props: { book: book(formats) }, global: { stubs: { RouterLink: RouterLinkStub } } })
|
const wrapper = mount(BookMissingFormatRequest, { props: { book: book(formats, readAlong) }, global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
return wrapper
|
return wrapper
|
||||||
}
|
}
|
||||||
@@ -63,6 +69,19 @@ describe('BookMissingFormatRequest', () => {
|
|||||||
expect(JSON.parse(init!.body as string).items[0]).toMatchObject({ title: 'Dune', mediaKind: 'audiobook', isbn13: '9780441013593' })
|
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 () => {
|
it('offers the ebook an audiobook is missing', async () => {
|
||||||
const wrapper = await render(['m4b'])
|
const wrapper = await render(['m4b'])
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { computed, ref, watch, type Ref } from 'vue'
|
|||||||
import { BOOK_REQUEST_FORMATS_BY_MEDIA_KIND } from '@bookorbit/types'
|
import { BOOK_REQUEST_FORMATS_BY_MEDIA_KIND } from '@bookorbit/types'
|
||||||
import type { BookDetail, BookRequestAvailability, BookRequestMediaKind, BookRequestSubmitResult, CreateBookRequestPayload } from '@bookorbit/types'
|
import type { BookDetail, BookRequestAvailability, BookRequestMediaKind, BookRequestSubmitResult, CreateBookRequestPayload } from '@bookorbit/types'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
import { hasReadAlong } from '@/features/book/lib/file-capabilities'
|
||||||
import { pairHalfState, type PairHalfState } from '../requestPair'
|
import { pairHalfState, type PairHalfState } from '../requestPair'
|
||||||
import { submitFailure, type SubmitFailure } from './useRequestSubmission'
|
import { submitFailure, type SubmitFailure } from './useRequestSubmission'
|
||||||
|
|
||||||
@@ -23,11 +24,11 @@ export function useMissingFormatRequest(book: Ref<BookDetail | null>, enabled: R
|
|||||||
|
|
||||||
const missing = computed<BookRequestMediaKind | null>(() => {
|
const missing = computed<BookRequestMediaKind | null>(() => {
|
||||||
if (!book.value) return null
|
if (!book.value) return null
|
||||||
const formats = book.value.files
|
const files = book.value.files.filter((file) => file.role === 'content' || file.role === 'primary')
|
||||||
.filter((file) => file.role === 'content' || file.role === 'primary')
|
const formats = files.map((file) => file.format?.toLowerCase() ?? '')
|
||||||
.map((file) => file.format?.toLowerCase() ?? '')
|
|
||||||
const hasReadable = formats.some((format) => BOOK_REQUEST_FORMATS_BY_MEDIA_KIND.ebook.includes(format))
|
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 (hasReadable && !hasAudio) return 'audiobook'
|
||||||
if (hasAudio && !hasReadable) return 'ebook'
|
if (hasAudio && !hasReadable) return 'ebook'
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import BookDetailTabs from './BookDetailTabs.vue'
|
import BookDetailTabs from './BookDetailTabs.vue'
|
||||||
import { useBookNavigation } from '../../composables/useBookNavigation'
|
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 { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -48,7 +48,7 @@ async function navigateToBook(id: number) {
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
<span class="me-2 h-4 w-px shrink-0 self-center bg-border" aria-hidden="true" />
|
<span class="me-2 h-4 w-px shrink-0 self-center bg-border" aria-hidden="true" />
|
||||||
</template>
|
</template>
|
||||||
<BookDetailTabs :book-id="bookId" :can-edit-metadata="canEditMetadata" :has-audio="hasAudio" />
|
<BookDetailTabs :book-id="bookId" :can-edit-metadata="canEditMetadata" :has-chapters="hasChapters" />
|
||||||
|
|
||||||
<div v-if="hasContext" class="hidden sm:flex items-center gap-1 ml-4 border-l pl-4">
|
<div v-if="hasContext" class="hidden sm:flex items-center gap-1 ml-4 border-l pl-4">
|
||||||
<span v-if="index !== -1" class="text-xs text-muted-foreground mr-2 font-medium tabular-nums">
|
<span v-if="index !== -1" class="text-xs text-muted-foreground mr-2 font-medium tabular-nums">
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ const props = withDefaults(
|
|||||||
bookId: number
|
bookId: number
|
||||||
canEditMetadata?: boolean
|
canEditMetadata?: boolean
|
||||||
coverTint?: CoverTint | null
|
coverTint?: CoverTint | null
|
||||||
hasAudio?: boolean
|
hasChapters?: boolean
|
||||||
parentLibrary?: { id: number; name: string } | null
|
parentLibrary?: { id: number; name: string } | null
|
||||||
}>(),
|
}>(),
|
||||||
{ coverTint: null, hasAudio: false, parentLibrary: null },
|
{ coverTint: null, hasChapters: false, parentLibrary: null },
|
||||||
)
|
)
|
||||||
|
|
||||||
const tintVars = computed(() => coverTintVars(props.coverTint))
|
const tintVars = computed(() => coverTintVars(props.coverTint))
|
||||||
@@ -25,7 +25,7 @@ const tintVars = computed(() => coverTintVars(props.coverTint))
|
|||||||
]"
|
]"
|
||||||
:style="tintVars"
|
:style="tintVars"
|
||||||
>
|
>
|
||||||
<BookDetailHeader :book-id="bookId" :can-edit-metadata="canEditMetadata" :has-audio="hasAudio" :parent-library="parentLibrary" />
|
<BookDetailHeader :book-id="bookId" :can-edit-metadata="canEditMetadata" :has-chapters="hasChapters" :parent-library="parentLibrary" />
|
||||||
<main class="flex-1 overflow-y-auto overflow-x-hidden p-4 sm:px-6 sm:py-4">
|
<main class="flex-1 overflow-y-auto overflow-x-hidden p-4 sm:px-6 sm:py-4">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { normalizeBookDetailTab, type BookDetailTab } from '@/features/book/lib/book-detail-tabs'
|
import { normalizeBookDetailTab, type BookDetailTab } from '@/features/book/lib/book-detail-tabs'
|
||||||
|
|
||||||
const props = defineProps<{ bookId: number; canEditMetadata?: boolean; hasAudio?: boolean }>()
|
const props = defineProps<{ bookId: number; canEditMetadata?: boolean; hasChapters?: boolean }>()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -20,7 +20,7 @@ const tabs = computed<{ label: string; tab: BookDetailTab }[]>(() => {
|
|||||||
result.push({ label: t('book.detail.tabs.editMetadata'), tab: 'edit' })
|
result.push({ label: t('book.detail.tabs.editMetadata'), tab: 'edit' })
|
||||||
}
|
}
|
||||||
result.push({ label: t('book.detail.tabs.files'), tab: 'files' })
|
result.push({ label: t('book.detail.tabs.files'), tab: 'files' })
|
||||||
if (props.hasAudio) result.push({ label: t('book.detail.tabs.chapters'), tab: 'chapters' })
|
if (props.hasChapters) result.push({ label: t('book.detail.tabs.chapters'), tab: 'chapters' })
|
||||||
result.push({ label: t('book.detail.tabs.readingLog'), tab: 'reading-log' })
|
result.push({ label: t('book.detail.tabs.readingLog'), tab: 'reading-log' })
|
||||||
result.push({ label: t('book.detail.tabs.highlights'), tab: 'highlights' })
|
result.push({ label: t('book.detail.tabs.highlights'), tab: 'highlights' })
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const props = defineProps<{
|
|||||||
totalDurationMs: number | null
|
totalDurationMs: number | null
|
||||||
/** Where the reader last listened, for the "you are here" marker. */
|
/** Where the reader last listened, for the "you are here" marker. */
|
||||||
listeningMs: number | null
|
listeningMs: number | null
|
||||||
|
/** True when a row can only open the reader where the book stands, because its start cannot be played from. */
|
||||||
|
openOnly?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -251,7 +253,7 @@ defineExpose({ scrollToKey, commitPendingStarts })
|
|||||||
type="button"
|
type="button"
|
||||||
class="listen-grid listen-row h-9 w-full items-center rounded-[10px] px-2.5 text-start transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
class="listen-grid listen-row h-9 w-full items-center rounded-[10px] px-2.5 text-start transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||||
:class="listenState(virtual.index) === 'now' ? 'bg-primary/15 shadow-[inset_2px_0_0_var(--primary)]' : 'hover:bg-muted/55'"
|
:class="listenState(virtual.index) === 'now' ? 'bg-primary/15 shadow-[inset_2px_0_0_var(--primary)]' : 'hover:bg-muted/55'"
|
||||||
:aria-label="t('book.detail.chapters.playFrom', { title: row(virtual.index).title })"
|
:aria-label="t(openOnly ? 'book.detail.chapters.openInReader' : 'book.detail.chapters.playFrom', { title: row(virtual.index).title })"
|
||||||
:aria-current="listenState(virtual.index) === 'now' ? 'true' : undefined"
|
:aria-current="listenState(virtual.index) === 'now' ? 'true' : undefined"
|
||||||
@click="handleListen(row(virtual.index).startMs)"
|
@click="handleListen(row(virtual.index).startMs)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ function slotRatio(slot: BookCoverSlot): number | null {
|
|||||||
|
|
||||||
const sourceLine = computed(() => {
|
const sourceLine = computed(() => {
|
||||||
const { source, updatedBy, updatedAt } = props.state
|
const { source, updatedBy, updatedAt } = props.state
|
||||||
|
if (props.state.readAlong) return t('book.detail.chapters.source.readAlong')
|
||||||
if (source === 'manual') {
|
if (source === 'manual') {
|
||||||
const when = updatedAt ? formatRelativeTimeFromNow(updatedAt) : null
|
const when = updatedAt ? formatRelativeTimeFromNow(updatedAt) : null
|
||||||
if (updatedBy && when) return t('book.detail.chapters.source.manualBy', { name: updatedBy.name, when })
|
if (updatedBy && when) return t('book.detail.chapters.source.manualBy', { name: updatedBy.name, when })
|
||||||
@@ -113,6 +114,7 @@ function handleDone() {
|
|||||||
|
|
||||||
<div class="hero-actions flex shrink-0 items-center gap-2 border-s border-border ps-4">
|
<div class="hero-actions flex shrink-0 items-center gap-2 border-s border-border ps-4">
|
||||||
<span
|
<span
|
||||||
|
v-if="!state.readAlong"
|
||||||
class="inline-flex h-[18px] items-center gap-1 rounded px-1.5 text-[9.5px] font-bold uppercase tracking-wider"
|
class="inline-flex h-[18px] items-center gap-1 rounded px-1.5 text-[9.5px] font-bold uppercase tracking-wider"
|
||||||
:class="state.locked ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'"
|
:class="state.locked ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'"
|
||||||
:title="state.locked ? t('book.detail.chapters.protectedHint') : t('book.detail.chapters.unprotectedHint')"
|
:title="state.locked ? t('book.detail.chapters.protectedHint') : t('book.detail.chapters.unprotectedHint')"
|
||||||
|
|||||||
@@ -66,4 +66,16 @@ describe('ChaptersHeroBar', () => {
|
|||||||
|
|
||||||
expect(cover.attributes('style')).toContain('aspect-ratio: 1')
|
expect(cover.attributes('style')).toContain('aspect-ratio: 1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('says a read-along list is view only and offers no protection to switch', async () => {
|
||||||
|
await setI18nLocale('en')
|
||||||
|
const state = { ...STATE, source: null, readAlong: true } as unknown as BookChaptersState
|
||||||
|
wrapper = mount(ChaptersHeroBar, { props: { book: book({}), state, editing: false, canEdit: false, chapterCount: 1 } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('From the read-along narration in the EPUB, view only')
|
||||||
|
expect(wrapper.text()).not.toContain('Not protected')
|
||||||
|
expect(wrapper.text()).not.toContain('Protected')
|
||||||
|
expect(wrapper.find('button').exists()).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ const TOOLS: { tool: ChapterTool; key: string }[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const bookId = computed(() => props.book.id)
|
const bookId = computed(() => props.book.id)
|
||||||
const canEdit = computed(() => hasLibraryPermission(Permission.LibraryEditMetadata, props.book.libraryId, 'editor'))
|
// A book with only a read-along EPUB has narration to list but no audio files to cut.
|
||||||
|
const hasAudioFiles = computed(() => props.book.audioMetadata != null)
|
||||||
|
const canEdit = computed(() => hasAudioFiles.value && hasLibraryPermission(Permission.LibraryEditMetadata, props.book.libraryId, 'editor'))
|
||||||
|
|
||||||
const store = useBookChapters(bookId)
|
const store = useBookChapters(bookId)
|
||||||
const { state, loading, loadError, busy } = store
|
const { state, loading, loadError, busy } = store
|
||||||
@@ -93,6 +95,7 @@ const listeningMs = ref<number | null>(null)
|
|||||||
|
|
||||||
const showSkeleton = useDeferredLoading(computed(() => loading.value && state.value === null))
|
const showSkeleton = useDeferredLoading(computed(() => loading.value && state.value === null))
|
||||||
const trackChapters = computed(() => (state.value ? chaptersFromTracks(state.value.tracks) : null))
|
const trackChapters = computed(() => (state.value ? chaptersFromTracks(state.value.tracks) : null))
|
||||||
|
const readAlong = computed(() => state.value?.readAlong === true)
|
||||||
|
|
||||||
// ── Loading ──────────────────────────────────────────────────────────────────
|
// ── Loading ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -117,6 +120,10 @@ async function refresh(options: { silent?: boolean } = {}) {
|
|||||||
|
|
||||||
/** The listener's place in the book, for the "you are here" marker. Derived from the saved percentage. */
|
/** The listener's place in the book, for the "you are here" marker. Derived from the saved percentage. */
|
||||||
async function loadListening() {
|
async function loadListening() {
|
||||||
|
if (!hasAudioFiles.value) {
|
||||||
|
listeningMs.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await api(`/api/v1/audiobooks/${bookId.value}/playback-state`)
|
const response = await api(`/api/v1/audiobooks/${bookId.value}/playback-state`)
|
||||||
if (!response.ok) return
|
if (!response.ok) return
|
||||||
@@ -400,6 +407,12 @@ function handleSettle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openInReader(startMs: number) {
|
function openInReader(startMs: number) {
|
||||||
|
if (readAlong.value) {
|
||||||
|
const readAlongFileId = state.value?.sources.readAlongFileId
|
||||||
|
if (readAlongFileId != null)
|
||||||
|
void router.push({ name: 'reader', params: { bookId: bookId.value, fileId: readAlongFileId }, query: { format: 'epub' } })
|
||||||
|
return
|
||||||
|
}
|
||||||
const fileId = state.value?.tracks[0]?.fileId
|
const fileId = state.value?.tracks[0]?.fileId
|
||||||
const format = props.book.files.find((file) => file.id === fileId)?.format
|
const format = props.book.files.find((file) => file.id === fileId)?.format
|
||||||
if (fileId === undefined || !format) return
|
if (fileId === undefined || !format) return
|
||||||
@@ -921,6 +934,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
:issues-by-key="issuesByKey"
|
:issues-by-key="issuesByKey"
|
||||||
:total-duration-ms="totalDurationMs"
|
:total-duration-ms="totalDurationMs"
|
||||||
:listening-ms="editing ? null : listeningMs"
|
:listening-ms="editing ? null : listeningMs"
|
||||||
|
:open-only="readAlong"
|
||||||
@activate="handleActivate"
|
@activate="handleActivate"
|
||||||
@toggle-select="handleToggleSelect"
|
@toggle-select="handleToggleSelect"
|
||||||
@update-title="handleUpdateTitle"
|
@update-title="handleUpdateTitle"
|
||||||
@@ -1037,7 +1051,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
</template>
|
</template>
|
||||||
<template #band>
|
<template #band>
|
||||||
<ChapterListenBand
|
<ChapterListenBand
|
||||||
v-if="!editing && totalDurationMs && rows.length"
|
v-if="!editing && !readAlong && totalDurationMs && rows.length"
|
||||||
:rows="rows"
|
:rows="rows"
|
||||||
:total-duration-ms="totalDurationMs"
|
:total-duration-ms="totalDurationMs"
|
||||||
:listening-ms="listeningMs"
|
:listening-ms="listeningMs"
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { BookDetail } from '@bookorbit/types'
|
||||||
|
import { hasChaptersTab } from '../book-detail-tabs'
|
||||||
|
|
||||||
|
type TabBook = Pick<BookDetail, 'audioMetadata' | 'files'>
|
||||||
|
|
||||||
|
function book(files: Array<Record<string, unknown>>, audioMetadata: unknown = null): TabBook {
|
||||||
|
return { audioMetadata, files } as unknown as TabBook
|
||||||
|
}
|
||||||
|
|
||||||
|
const READ_ALONG = { id: 1, format: 'epub', role: 'content', mediaOverlay: { available: true, durationSeconds: 3600 } }
|
||||||
|
|
||||||
|
describe('hasChaptersTab', () => {
|
||||||
|
it('is off until there is a book', () => {
|
||||||
|
expect(hasChaptersTab(null)).toBe(false)
|
||||||
|
expect(hasChaptersTab(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is on for a book with audio files', () => {
|
||||||
|
expect(hasChaptersTab(book([{ id: 1, format: 'm4b', role: 'content' }], { narrators: [] }))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is on for an EPUB that carries read-along narration', () => {
|
||||||
|
expect(hasChaptersTab(book([READ_ALONG]))).toBe(true)
|
||||||
|
expect(hasChaptersTab(book([{ ...READ_ALONG, role: 'primary' }]))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a plain EPUB', [{ id: 1, format: 'epub', role: 'content', mediaOverlay: { available: false, durationSeconds: null } }]],
|
||||||
|
['an EPUB never checked for narration', [{ id: 1, format: 'epub', role: 'content' }]],
|
||||||
|
['a supplementary read-along EPUB', [{ ...READ_ALONG, role: 'supplement' }]],
|
||||||
|
['a book with neither', [{ id: 1, format: 'pdf', role: 'content' }]],
|
||||||
|
])('is off for %s', (_label, files) => {
|
||||||
|
expect(hasChaptersTab(book(files))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { isContentBookFile, type BookDetail } from '@bookorbit/types'
|
||||||
|
import { hasReadAlong } from './file-capabilities'
|
||||||
|
|
||||||
export const BOOK_DETAIL_TABS = ['details', 'edit', 'files', 'chapters', 'reading-log', 'highlights'] as const
|
export const BOOK_DETAIL_TABS = ['details', 'edit', 'files', 'chapters', 'reading-log', 'highlights'] as const
|
||||||
|
|
||||||
export type BookDetailTab = (typeof BOOK_DETAIL_TABS)[number]
|
export type BookDetailTab = (typeof BOOK_DETAIL_TABS)[number]
|
||||||
@@ -8,3 +11,10 @@ export function normalizeBookDetailTab(value: unknown): BookDetailTab {
|
|||||||
}
|
}
|
||||||
return 'details'
|
return 'details'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The Chapters tab has audio files to lay out, or a read-along EPUB whose narration it can list. */
|
||||||
|
export function hasChaptersTab(book: Pick<BookDetail, 'audioMetadata' | 'files'> | null | undefined): boolean {
|
||||||
|
if (!book) return false
|
||||||
|
if (book.audioMetadata != null) return true
|
||||||
|
return book.files.some((file) => isContentBookFile(file) && hasReadAlong(file))
|
||||||
|
}
|
||||||
|
|||||||
@@ -7696,10 +7696,12 @@
|
|||||||
"migration": "Imported from Audiobookshelf",
|
"migration": "Imported from Audiobookshelf",
|
||||||
"manual": "Edited in BookOrbit",
|
"manual": "Edited in BookOrbit",
|
||||||
"manualBy": "Edited by {name} {when}",
|
"manualBy": "Edited by {name} {when}",
|
||||||
|
"readAlong": "From the read-along narration in the EPUB, view only",
|
||||||
"none": "No chapters yet"
|
"none": "No chapters yet"
|
||||||
},
|
},
|
||||||
"youAreHere": "You are here",
|
"youAreHere": "You are here",
|
||||||
"playFrom": "Listen from {title}",
|
"playFrom": "Listen from {title}",
|
||||||
|
"openInReader": "Open {title} in the reader",
|
||||||
"selectRow": "Select chapter {index}",
|
"selectRow": "Select chapter {index}",
|
||||||
"selectedCount": "{count, plural, one {# selected} other {# selected}}",
|
"selectedCount": "{count, plural, one {# selected} other {# selected}}",
|
||||||
"selectAll": "Select all",
|
"selectAll": "Select all",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useBookDetail } from '@/features/book/composables/useBookDetail'
|
|||||||
import { useBookEvents } from '@/features/book/composables/useBookEvents'
|
import { useBookEvents } from '@/features/book/composables/useBookEvents'
|
||||||
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
|
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
|
||||||
import { usePageTitle } from '@/composables/usePageTitle'
|
import { usePageTitle } from '@/composables/usePageTitle'
|
||||||
import { normalizeBookDetailTab } from '@/features/book/lib/book-detail-tabs'
|
import { hasChaptersTab, normalizeBookDetailTab } from '@/features/book/lib/book-detail-tabs'
|
||||||
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
|
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
|
||||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
|
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
|
||||||
@@ -56,11 +56,11 @@ const canEditMetadata = computed(() => hasLibraryPermission('library_edit_metada
|
|||||||
const parentLibrary = computed(() =>
|
const parentLibrary = computed(() =>
|
||||||
detail.value?.libraryId != null && detail.value.libraryName ? { id: detail.value.libraryId, name: detail.value.libraryName } : null,
|
detail.value?.libraryId != null && detail.value.libraryName ? { id: detail.value.libraryId, name: detail.value.libraryName } : null,
|
||||||
)
|
)
|
||||||
const hasAudio = computed(() => detail.value?.audioMetadata != null)
|
const hasChapters = computed(() => hasChaptersTab(detail.value))
|
||||||
|
|
||||||
// A chapters link to a book without audio (or one whose audio was removed) lands on the details.
|
// A chapters link to a book without audio or read-along narration (or one whose audio was removed) lands on the details.
|
||||||
watch([tab, detail], ([current, book]) => {
|
watch([tab, detail], ([current, book]) => {
|
||||||
if (current === 'chapters' && book && book.id === bookId.value && !hasAudio.value) {
|
if (current === 'chapters' && book && book.id === bookId.value && !hasChapters.value) {
|
||||||
void router.replace({ name: 'book-detail', params: { bookId: book.id }, query: { ...route.query, tab: 'details' } })
|
void router.replace({ name: 'book-detail', params: { bookId: book.id }, query: { ...route.query, tab: 'details' } })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -127,7 +127,7 @@ onMounted(() => {
|
|||||||
const warm = () => {
|
const warm = () => {
|
||||||
void loadReadingLogTab()
|
void loadReadingLogTab()
|
||||||
void loadHighlightsTab()
|
void loadHighlightsTab()
|
||||||
if (hasAudio.value) void loadChaptersTab()
|
if (hasChapters.value) void loadChaptersTab()
|
||||||
}
|
}
|
||||||
if (typeof requestIdleCallback === 'function') requestIdleCallback(warm, { timeout: 2000 })
|
if (typeof requestIdleCallback === 'function') requestIdleCallback(warm, { timeout: 2000 })
|
||||||
else setTimeout(warm, 500)
|
else setTimeout(warm, 500)
|
||||||
@@ -167,7 +167,7 @@ function onChaptersChanged() {
|
|||||||
:cover-tint="coverTint"
|
:cover-tint="coverTint"
|
||||||
:can-edit-metadata="canEditMetadata"
|
:can-edit-metadata="canEditMetadata"
|
||||||
:parent-library="parentLibrary"
|
:parent-library="parentLibrary"
|
||||||
:has-audio="hasAudio"
|
:has-chapters="hasChapters"
|
||||||
>
|
>
|
||||||
<Transition name="content" mode="out-in">
|
<Transition name="content" mode="out-in">
|
||||||
<div v-if="detail" key="detail" class="@container/book-detail h-full">
|
<div v-if="detail" key="detail" class="@container/book-detail h-full">
|
||||||
@@ -186,7 +186,7 @@ function onChaptersChanged() {
|
|||||||
@cover-changed="onCoverChanged"
|
@cover-changed="onCoverChanged"
|
||||||
/>
|
/>
|
||||||
<FilesTab v-else-if="tab === 'files'" :book="detail" @refetch="fetch(detail.id)" />
|
<FilesTab v-else-if="tab === 'files'" :book="detail" @refetch="fetch(detail.id)" />
|
||||||
<ChaptersTab v-else-if="tab === 'chapters' && hasAudio" :book="detail" @changed="onChaptersChanged" />
|
<ChaptersTab v-else-if="tab === 'chapters' && hasChapters" :book="detail" @changed="onChaptersChanged" />
|
||||||
<ReadingLogTab v-else-if="tab === 'reading-log'" :book="detail" @saved="onMetadataSaved" @refetch="handleBookRefetch" />
|
<ReadingLogTab v-else-if="tab === 'reading-log'" :book="detail" @saved="onMetadataSaved" @refetch="handleBookRefetch" />
|
||||||
<HighlightsTab v-else-if="tab === 'highlights'" :book="detail" />
|
<HighlightsTab v-else-if="tab === 'highlights'" :book="detail" />
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ export interface BookChaptersState {
|
|||||||
audibleRegion: AudibleRegion;
|
audibleRegion: AudibleRegion;
|
||||||
sources: BookChapterSources;
|
sources: BookChapterSources;
|
||||||
writeBack: BookChapterWriteBack;
|
writeBack: BookChapterWriteBack;
|
||||||
|
/**
|
||||||
|
* True when the book has no audio files and the chapters are read off a read-along EPUB's
|
||||||
|
* narration instead. That list can be shown but not edited, saved or written.
|
||||||
|
*/
|
||||||
|
readAlong?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveBookChaptersRequest {
|
export interface SaveBookChaptersRequest {
|
||||||
|
|||||||
@@ -291,6 +291,60 @@ describe('ChapterEditorService.getState', () => {
|
|||||||
await expect(service.getState(BOOK_ID, USER)).rejects.toThrow(NotFoundException);
|
await expect(service.getState(BOOK_ID, USER)).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refuses a book whose only EPUB has no narration', async () => {
|
||||||
|
const { service, epubService } = makeService({ tracks: [], ebookFiles: [ebookFile(30, false)] });
|
||||||
|
|
||||||
|
await expect(service.getState(BOOK_ID, USER)).rejects.toThrow(NotFoundException);
|
||||||
|
expect(epubService.getMediaOverlayPlaylist).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the narration of a read-along EPUB with no audio files as chapters that cannot be edited', async () => {
|
||||||
|
const { service, epubService, bookService } = makeService({ tracks: [], row: null, ebookFiles: [ebookFile(31, true)] });
|
||||||
|
epubService.getMediaOverlayPlaylist.mockResolvedValue({
|
||||||
|
durationSeconds: 3_600.4,
|
||||||
|
sections: [
|
||||||
|
{ index: 0, label: null, startSeconds: 0 },
|
||||||
|
{ index: 1, label: ' Chapter One ', startSeconds: 12.3456 },
|
||||||
|
{ index: 2, label: 'Chapter Two', startSeconds: 1_800 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await service.getState(BOOK_ID, USER);
|
||||||
|
|
||||||
|
expect(bookService.verifyBookAccess).toHaveBeenCalledWith(BOOK_ID, USER);
|
||||||
|
expect(epubService.getMediaOverlayPlaylist).toHaveBeenCalledWith(BOOK_ID, 31, USER);
|
||||||
|
expect(state).toMatchObject({
|
||||||
|
readAlong: true,
|
||||||
|
chapters: [
|
||||||
|
{ title: 'Chapter One', startMs: 12_346 },
|
||||||
|
{ title: 'Chapter Two', startMs: 1_800_000 },
|
||||||
|
],
|
||||||
|
source: null,
|
||||||
|
derived: false,
|
||||||
|
locked: false,
|
||||||
|
totalDurationMs: 3_600_400,
|
||||||
|
tracks: [],
|
||||||
|
versionCount: 0,
|
||||||
|
sources: { ebookFileId: 31, readAlongFileId: 31 },
|
||||||
|
writeBack: { available: false, reason: null, fileCount: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the audio files as the source of chapters when the book has both', async () => {
|
||||||
|
const { service, epubService } = makeService({ ebookFiles: [ebookFile(31, true)] });
|
||||||
|
|
||||||
|
const state = await service.getState(BOOK_ID, USER);
|
||||||
|
|
||||||
|
expect(state.readAlong).toBeUndefined();
|
||||||
|
expect(epubService.getMediaOverlayPlaylist).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still refuses to edit a read-along EPUB, which has no audio tracks to cut', async () => {
|
||||||
|
const { service } = makeService({ tracks: [], ebookFiles: [ebookFile(31, true)] });
|
||||||
|
|
||||||
|
await expect(service.save(BOOK_ID, { chapters: [{ title: 'One', startMs: 0 }], baseRevision: '' }, USER)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
it('changes the revision whenever the stored list, its source or its save time changes', async () => {
|
it('changes the revision whenever the stored list, its source or its save time changes', async () => {
|
||||||
const base = await currentRevision(makeService().service);
|
const base = await currentRevision(makeService().service);
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
type BookChapterVersion,
|
type BookChapterVersion,
|
||||||
type BookChapterVersionSummary,
|
type BookChapterVersionSummary,
|
||||||
type EmbeddedBookChapters,
|
type EmbeddedBookChapters,
|
||||||
|
type EpubMediaOverlayPlaylist,
|
||||||
} from '@bookorbit/types';
|
} from '@bookorbit/types';
|
||||||
|
|
||||||
import type { RequestUser } from '../../common/types/request-user';
|
import type { RequestUser } from '../../common/types/request-user';
|
||||||
@@ -120,7 +121,8 @@ export class ChapterEditorService {
|
|||||||
|
|
||||||
async getState(bookId: number, user: RequestUser): Promise<BookChaptersState> {
|
async getState(bookId: number, user: RequestUser): Promise<BookChaptersState> {
|
||||||
await this.bookService.verifyBookAccess(bookId, user);
|
await this.bookService.verifyBookAccess(bookId, user);
|
||||||
return this.buildState(bookId, await this.loadContext(bookId));
|
const context = await this.findContext(bookId);
|
||||||
|
return context ? this.buildState(bookId, context) : this.buildReadAlongState(bookId, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(bookId: number, dto: SaveBookChaptersDto, user: RequestUser): Promise<BookChaptersState> {
|
async save(bookId: number, dto: SaveBookChaptersDto, user: RequestUser): Promise<BookChaptersState> {
|
||||||
@@ -243,20 +245,22 @@ export class ChapterEditorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const playlist = await this.epubService.getMediaOverlayPlaylist(bookId, fileId, user);
|
const playlist = await this.epubService.getMediaOverlayPlaylist(bookId, fileId, user);
|
||||||
const chapters = normalizeAudiobookChapters(
|
const chapters = this.readAlongChapters(playlist);
|
||||||
|
if (chapters.length === 0)
|
||||||
|
throw this.coded(ConflictException, BOOK_CHAPTERS_ERROR_CODES.ebookUnavailable, 'The read-along EPUB names no sections');
|
||||||
|
return { source, fileId, chapters, titlesOnly: false, runtimeMs: this.readAlongRuntimeMs(playlist) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private readAlongChapters(playlist: EpubMediaOverlayPlaylist): AudiobookChapter[] {
|
||||||
|
return normalizeAudiobookChapters(
|
||||||
playlist.sections
|
playlist.sections
|
||||||
.filter((section) => section.label?.trim())
|
.filter((section) => section.label?.trim())
|
||||||
.map((section) => ({ title: section.label!.trim(), startMs: Math.round(section.startSeconds * 1000) })),
|
.map((section) => ({ title: section.label!.trim(), startMs: Math.round(section.startSeconds * 1000) })),
|
||||||
);
|
);
|
||||||
if (chapters.length === 0)
|
}
|
||||||
throw this.coded(ConflictException, BOOK_CHAPTERS_ERROR_CODES.ebookUnavailable, 'The read-along EPUB names no sections');
|
|
||||||
return {
|
private readAlongRuntimeMs(playlist: EpubMediaOverlayPlaylist): number | null {
|
||||||
source,
|
return playlist.durationSeconds === null ? null : Math.round(playlist.durationSeconds * 1000);
|
||||||
fileId,
|
|
||||||
chapters,
|
|
||||||
titlesOnly: false,
|
|
||||||
runtimeMs: playlist.durationSeconds === null ? null : Math.round(playlist.durationSeconds * 1000),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pauses near each point, for snapping chapter starts. A window whose audio will not decode is marked failed. */
|
/** Pauses near each point, for snapping chapter starts. A window whose audio will not decode is marked failed. */
|
||||||
@@ -387,14 +391,50 @@ export class ChapterEditorService {
|
|||||||
return { ...this.mapVersion(row), chapters: row.chapters };
|
return { ...this.mapVersion(row), chapters: row.chapters };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadContext(bookId: number): Promise<EditorContext> {
|
private async findContext(bookId: number): Promise<EditorContext | null> {
|
||||||
const [row, rows] = await Promise.all([this.repo.findChapterRow(bookId), this.repo.findTrackRows(bookId)]);
|
const [row, rows] = await Promise.all([this.repo.findChapterRow(bookId), this.repo.findTrackRows(bookId)]);
|
||||||
const tracks = orderAudiobookTracks(rows);
|
const tracks = orderAudiobookTracks(rows);
|
||||||
if (tracks.length === 0) throw new NotFoundException('Book has no audio tracks');
|
if (tracks.length === 0) return null;
|
||||||
const revision = computeChapterEditorRevision(row, rows);
|
const revision = computeChapterEditorRevision(row, rows);
|
||||||
return { bookId, row, rows, tracks, revision };
|
return { bookId, row, rows, tracks, revision };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadContext(bookId: number): Promise<EditorContext> {
|
||||||
|
const context = await this.findContext(bookId);
|
||||||
|
if (!context) throw new NotFoundException('Book has no audio tracks');
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A book with a read-along EPUB and no audio files has narration but nothing to cut, so its
|
||||||
|
* chapters are read off the EPUB for display. Every editing route still needs audio tracks.
|
||||||
|
*/
|
||||||
|
private async buildReadAlongState(bookId: number, user: RequestUser): Promise<BookChaptersState> {
|
||||||
|
const sources = this.ebookSources(await this.repo.findEbookFiles(bookId));
|
||||||
|
if (sources.readAlongFileId === null) throw new NotFoundException('Book has no audio tracks');
|
||||||
|
const [playlist, audibleRegion] = await Promise.all([
|
||||||
|
this.epubService.getMediaOverlayPlaylist(bookId, sources.readAlongFileId, user),
|
||||||
|
this.defaultAudibleRegion(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
chapters: this.readAlongChapters(playlist),
|
||||||
|
source: null,
|
||||||
|
derived: false,
|
||||||
|
locked: false,
|
||||||
|
revision: '',
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null,
|
||||||
|
totalDurationMs: this.readAlongRuntimeMs(playlist),
|
||||||
|
tracks: [],
|
||||||
|
audibleId: null,
|
||||||
|
versionCount: 0,
|
||||||
|
audibleRegion,
|
||||||
|
sources,
|
||||||
|
writeBack: { available: false, reason: null, fileCount: 0 },
|
||||||
|
readAlong: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async buildState(bookId: number, context: EditorContext): Promise<BookChaptersState> {
|
private async buildState(bookId: number, context: EditorContext): Promise<BookChaptersState> {
|
||||||
const [locked, versionCount, ebookFiles, audibleRegion, writeBack] = await Promise.all([
|
const [locked, versionCount, ebookFiles, audibleRegion, writeBack] = await Promise.all([
|
||||||
this.lockService.isFieldLocked(bookId, 'chapters'),
|
this.lockService.isFieldLocked(bookId, 'chapters'),
|
||||||
|
|||||||
Reference in New Issue
Block a user