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:
neon
2026-11-01 17:05:33 +00:00
parent ba315c7718
commit 82608dbafd
16 changed files with 235 additions and 38 deletions
@@ -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'])
@@ -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<BookDetail | null>, enabled: R
const missing = computed<BookRequestMediaKind | null>(() => {
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
@@ -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) {
</RouterLink>
<span class="me-2 h-4 w-px shrink-0 self-center bg-border" aria-hidden="true" />
</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">
<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
canEditMetadata?: boolean
coverTint?: CoverTint | null
hasAudio?: boolean
hasChapters?: boolean
parentLibrary?: { id: number; name: string } | null
}>(),
{ coverTint: null, hasAudio: false, parentLibrary: null },
{ coverTint: null, hasChapters: false, parentLibrary: null },
)
const tintVars = computed(() => coverTintVars(props.coverTint))
@@ -25,7 +25,7 @@ const tintVars = computed(() => coverTintVars(props.coverTint))
]"
: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">
<slot />
</main>
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
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 router = useRouter()
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.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.highlights'), tab: 'highlights' })
return result
@@ -17,6 +17,8 @@ const props = defineProps<{
totalDurationMs: number | null
/** Where the reader last listened, for the "you are here" marker. */
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<{
@@ -251,7 +253,7 @@ defineExpose({ scrollToKey, commitPendingStarts })
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="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"
@click="handleListen(row(virtual.index).startMs)"
>
@@ -49,6 +49,7 @@ function slotRatio(slot: BookCoverSlot): number | null {
const sourceLine = computed(() => {
const { source, updatedBy, updatedAt } = props.state
if (props.state.readAlong) return t('book.detail.chapters.source.readAlong')
if (source === 'manual') {
const when = updatedAt ? formatRelativeTimeFromNow(updatedAt) : null
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">
<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="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')"
@@ -66,4 +66,16 @@ describe('ChaptersHeroBar', () => {
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 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 { 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 trackChapters = computed(() => (state.value ? chaptersFromTracks(state.value.tracks) : null))
const readAlong = computed(() => state.value?.readAlong === true)
// ── 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. */
async function loadListening() {
if (!hasAudioFiles.value) {
listeningMs.value = null
return
}
try {
const response = await api(`/api/v1/audiobooks/${bookId.value}/playback-state`)
if (!response.ok) return
@@ -400,6 +407,12 @@ function handleSettle() {
}
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 format = props.book.files.find((file) => file.id === fileId)?.format
if (fileId === undefined || !format) return
@@ -921,6 +934,7 @@ function handleKeydown(event: KeyboardEvent) {
:issues-by-key="issuesByKey"
:total-duration-ms="totalDurationMs"
:listening-ms="editing ? null : listeningMs"
:open-only="readAlong"
@activate="handleActivate"
@toggle-select="handleToggleSelect"
@update-title="handleUpdateTitle"
@@ -1037,7 +1051,7 @@ function handleKeydown(event: KeyboardEvent) {
</template>
<template #band>
<ChapterListenBand
v-if="!editing && totalDurationMs && rows.length"
v-if="!editing && !readAlong && totalDurationMs && rows.length"
:rows="rows"
:total-duration-ms="totalDurationMs"
: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 type BookDetailTab = (typeof BOOK_DETAIL_TABS)[number]
@@ -8,3 +11,10 @@ export function normalizeBookDetailTab(value: unknown): BookDetailTab {
}
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))
}
+2
View File
@@ -7696,10 +7696,12 @@
"migration": "Imported from Audiobookshelf",
"manual": "Edited in BookOrbit",
"manualBy": "Edited by {name} {when}",
"readAlong": "From the read-along narration in the EPUB, view only",
"none": "No chapters yet"
},
"youAreHere": "You are here",
"playFrom": "Listen from {title}",
"openInReader": "Open {title} in the reader",
"selectRow": "Select chapter {index}",
"selectedCount": "{count, plural, one {# selected} other {# selected}}",
"selectAll": "Select all",
+7 -7
View File
@@ -11,7 +11,7 @@ import { useBookDetail } from '@/features/book/composables/useBookDetail'
import { useBookEvents } from '@/features/book/composables/useBookEvents'
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
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 { useLibraries } from '@/features/library/composables/useLibraries'
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(() =>
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]) => {
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' } })
}
})
@@ -127,7 +127,7 @@ onMounted(() => {
const warm = () => {
void loadReadingLogTab()
void loadHighlightsTab()
if (hasAudio.value) void loadChaptersTab()
if (hasChapters.value) void loadChaptersTab()
}
if (typeof requestIdleCallback === 'function') requestIdleCallback(warm, { timeout: 2000 })
else setTimeout(warm, 500)
@@ -167,7 +167,7 @@ function onChaptersChanged() {
:cover-tint="coverTint"
:can-edit-metadata="canEditMetadata"
:parent-library="parentLibrary"
:has-audio="hasAudio"
:has-chapters="hasChapters"
>
<Transition name="content" mode="out-in">
<div v-if="detail" key="detail" class="@container/book-detail h-full">
@@ -186,7 +186,7 @@ function onChaptersChanged() {
@cover-changed="onCoverChanged"
/>
<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" />
<HighlightsTab v-else-if="tab === 'highlights'" :book="detail" />
</KeepAlive>
+5
View File
@@ -68,6 +68,11 @@ export interface BookChaptersState {
audibleRegion: AudibleRegion;
sources: BookChapterSources;
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 {
@@ -291,6 +291,60 @@ describe('ChapterEditorService.getState', () => {
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 () => {
const base = await currentRevision(makeService().service);
@@ -35,6 +35,7 @@ import {
type BookChapterVersion,
type BookChapterVersionSummary,
type EmbeddedBookChapters,
type EpubMediaOverlayPlaylist,
} from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
@@ -120,7 +121,8 @@ export class ChapterEditorService {
async getState(bookId: number, user: RequestUser): Promise<BookChaptersState> {
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> {
@@ -243,20 +245,22 @@ export class ChapterEditorService {
}
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
.filter((section) => section.label?.trim())
.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 {
source,
fileId,
chapters,
titlesOnly: false,
runtimeMs: playlist.durationSeconds === null ? null : Math.round(playlist.durationSeconds * 1000),
};
}
private readAlongRuntimeMs(playlist: EpubMediaOverlayPlaylist): number | null {
return 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. */
@@ -387,14 +391,50 @@ export class ChapterEditorService {
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 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);
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> {
const [locked, versionCount, ebookFiles, audibleRegion, writeBack] = await Promise.all([
this.lockService.isFieldLocked(bookId, 'chapters'),