From d21c68d13cedbc96489da0e6c2d35f929a61a429 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:25:48 +0000 Subject: [PATCH] feat(books): add an audiobook chapter editor Add a Chapters tab to edit chapter timing and titles, look chapters up on Audible or in the ebook, snap starts to pauses, keep a version history and write chapters into M4B, M4A and MP3 files. Add a chapter health tool with a bulk Audible lookup queue. Editing needs Editor access to the book's library. Chapter edits keep released iOS playback working by accepting a stale manifest revision while the track layout is unchanged. Ref #1120 --- .../__tests__/useSidebarNav.spec.ts | 19 +- client/src/composables/useSidebarNav.ts | 10 +- .../components/detail/BookDetailHeader.vue | 4 +- .../components/detail/BookDetailLayout.vue | 12 +- .../book/components/detail/BookDetailTabs.vue | 3 +- .../detail/chapters/ChapterCutCard.vue | 440 + .../detail/chapters/ChapterHistoryMenu.vue | 77 + .../detail/chapters/ChapterListCard.vue | 444 + .../detail/chapters/ChapterListenBand.vue | 82 + .../detail/chapters/ChapterSaveBar.vue | 175 + .../detail/chapters/ChapterSnapDialog.vue | 295 + .../detail/chapters/ChapterSourceSheet.vue | 634 + .../detail/chapters/ChapterTimeline.vue | 233 + .../detail/chapters/ChapterToolDialog.vue | 280 + .../detail/chapters/ChapterWaveformStrip.vue | 350 + .../detail/chapters/ChaptersHeroBar.vue | 171 + .../chapters/__tests__/ChapterCutCard.spec.ts | 184 + .../__tests__/ChapterListCard.spec.ts | 99 + .../chapters/__tests__/ChapterSaveBar.spec.ts | 83 + .../__tests__/ChapterSnapDialog.spec.ts | 102 + .../__tests__/ChapterSourceSheet.spec.ts | 198 + .../__tests__/ChapterTimeline.spec.ts | 83 + .../__tests__/ChapterToolDialog.spec.ts | 84 + .../__tests__/ChapterWaveformStrip.spec.ts | 293 + .../__tests__/ChaptersHeroBar.spec.ts | 69 + .../components/detail/tabs/ChaptersTab.vue | 1221 + .../__tests__/useBookChapters.spec.ts | 114 + .../__tests__/useChapterDraft.spec.ts | 169 + .../__tests__/useChapterPreview.spec.ts | 208 + .../__tests__/useChapterReview.spec.ts | 171 + .../__tests__/useChapterWaveform.spec.ts | 254 + .../book/composables/useBookChapters.ts | 226 + .../book/composables/useChapterDraft.ts | 191 + .../book/composables/useChapterPreview.ts | 214 + .../book/composables/useChapterReview.ts | 147 + .../book/composables/useChapterWaveform.ts | 96 + .../book/lib/__tests__/chapter-align.spec.ts | 186 + .../book/lib/__tests__/chapter-edit.spec.ts | 205 + .../book/lib/__tests__/chapter-io.spec.ts | 801 + .../book/lib/__tests__/chapter-snap.spec.ts | 203 + .../book/lib/__tests__/chapter-time.spec.ts | 47 + .../src/features/book/lib/book-detail-tabs.ts | 2 +- client/src/features/book/lib/chapter-align.ts | 234 + client/src/features/book/lib/chapter-edit.ts | 240 + client/src/features/book/lib/chapter-io.ts | 691 + client/src/features/book/lib/chapter-snap.ts | 222 + client/src/features/book/lib/chapter-time.ts | 53 + .../reader/audiobook/AudiobookReaderView.vue | 20 +- .../src/features/tools/api/chapter-health.ts | 53 + .../components/ChapterHealthCategoryTabs.vue | 47 + .../components/ChapterHealthLookupPanel.vue | 88 + .../components/ChapterHealthRow.vue | 94 + .../__tests__/ChapterHealthRow.spec.ts | 170 + .../views/ChapterHealthView.spec.ts | 259 + .../views/ChapterHealthView.vue | 198 + .../features/tools/components/ToolsHeader.vue | 4 + .../__tests__/useChapterHealth.spec.ts | 384 + .../tools/composables/useChapterHealth.ts | 205 + client/src/lib/audio-focus.ts | 9 +- client/src/locales/en.json | 444 +- .../router/__tests__/title-resolver.spec.ts | 17 + client/src/router/index.ts | 6 + client/src/router/title-resolver.ts | 4 +- client/src/views/BookDetailView.vue | 34 +- packages/types/src/audiobook.ts | 223 + packages/types/src/book.ts | 5 +- packages/types/src/chapter-health.ts | 68 + packages/types/src/index.ts | 1 + packages/types/src/metadata-lock.ts | 1 + scripts/e2e/suite-registry.mjs | 74 + server/.env.example | 3 + server/src/app.module.ts | 6 + server/src/common/common.module.ts | 5 +- .../src/common/services/file-lock.service.ts | 30 + .../utils/audiobook-chapters.utils.test.ts | 296 + .../common/utils/audiobook-chapters.utils.ts | 217 + .../src/common/utils/request-abort.utils.ts | 16 + server/src/config/config.ts | 6 + server/src/config/env.validation.ts | 4 + .../0127_add_audiobook_chapter_editor.sql | 84 + .../src/db/migrations/meta/0127_snapshot.json | 25470 ++++++++++++++++ server/src/db/migrations/meta/_journal.json | 7 + server/src/db/preserve-audiobook-revision.ts | 105 + server/src/db/schema/books.ts | 8 + server/src/db/schema/metadata.ts | 94 + server/src/db/schema/reader.ts | 16 + .../audiobook-chapters.module.ts | 12 + .../audiobook-chapters.repository.ts | 225 + .../audiobook-chapters.service.test.ts | 142 + .../audiobook-chapters.service.ts | 170 + .../src/modules/audiobook/audiobook.module.ts | 3 +- .../audiobook/audiobook.service.test.ts | 131 +- .../modules/audiobook/audiobook.service.ts | 43 +- .../book-dock-finalize.service.test.ts | 3 +- .../book-dock/book-dock-finalize.service.ts | 14 +- ...etadata-fetch-orchestrator.service.test.ts | 4 + ...ook-metadata-fetch-orchestrator.service.ts | 10 +- .../book-metadata-fetch.module.ts | 2 + .../book-metadata-lock.service.test.ts | 24 +- .../book-metadata-lock.service.ts | 12 +- server/src/modules/book/book.module.ts | 2 + server/src/modules/book/book.repository.ts | 11 +- server/src/modules/book/book.service.test.ts | 129 +- server/src/modules/book/book.service.ts | 57 +- .../src/modules/book/dto/book-detail.dto.ts | 3 + ...ld-book-detail-supplemental-fields.test.ts | 2 + .../build-book-detail-supplemental-fields.ts | 6 +- .../chapter-editor/chapter-auto-apply.test.ts | 259 + .../chapter-editor/chapter-auto-apply.ts | 57 + .../chapter-editor.controller.ts | 167 + .../chapter-editor/chapter-editor.module.ts | 34 + .../chapter-editor.repository.ts | 92 + .../chapter-editor.service.test.ts | 1179 + .../chapter-editor/chapter-editor.service.ts | 591 + .../chapter-editor/chapter-job.repository.ts | 108 + .../chapter-job.service.test.ts | 92 + .../chapter-editor/chapter-job.service.ts | 109 + .../chapter-lookup.service.test.ts | 228 + .../chapter-editor/chapter-lookup.service.ts | 219 + .../chapter-pause.service.test.ts | 441 + .../chapter-editor/chapter-pause.service.ts | 222 + .../dto/analyze-book-chapters.dto.ts | 17 + .../dto/ebook-book-chapters.dto.ts | 7 + .../dto/lookup-book-chapters.dto.ts | 14 + .../dto/revert-book-chapters.dto.ts | 8 + .../dto/save-book-chapters.dto.ts | 42 + .../dto/start-chapter-job.dto.ts | 8 + .../dto/waveform-book-chapters.dto.ts | 24 + .../dto/write-book-chapters.dto.ts | 8 + .../chapter-health.controller.test.ts | 52 + .../chapter-health.controller.ts | 51 + .../chapter-health/chapter-health.module.ts | 15 + .../chapter-health.repository.ts | 373 + .../chapter-health.service.test.ts | 241 + .../chapter-health/chapter-health.service.ts | 131 + .../chapter-lookup.worker.test.ts | 192 + .../chapter-health/chapter-lookup.worker.ts | 92 + .../chapter-health/dto/chapter-health.dto.ts | 48 + .../modules/file-write/file-lock.service.ts | 31 +- .../file-write/file-write-capability.ts | 22 + .../file-write/file-write.module.test.ts | 5 +- .../modules/file-write/file-write.module.ts | 16 +- .../file-write/file-write.repository.ts | 11 + .../file-write/file-write.service.test.ts | 263 +- .../modules/file-write/file-write.service.ts | 207 +- .../formats/audio/audio-format-writer.test.ts | 43 + .../formats/audio/audio-format-writer.ts | 5 + .../audio/audio-metadata-embedder.test.ts | 181 +- .../formats/audio/audio-metadata-embedder.ts | 90 +- .../audio/audio-metadata-roundtrip.test.ts | 22 +- .../format-write-options.interface.ts | 3 + .../metadata-fetch-pipeline.test.ts | 30 +- .../metadata-fetch/metadata-fetch-pipeline.ts | 14 +- .../extractors/audio-chapter-merge.test.ts | 99 + .../extractors/audio-chapter-merge.ts | 23 + .../metadata/extractors/audio.extractor.ts | 12 +- .../src/modules/metadata/metadata.module.ts | 11 +- .../modules/metadata/metadata.service.test.ts | 156 +- .../src/modules/metadata/metadata.service.ts | 88 +- .../audiobookshelf-api.connector.test.ts | 9 + .../audiobookshelf-api.connector.ts | 8 + .../audiobookshelf-backup.connector.test.ts | 15 +- .../audiobookshelf-backup.connector.ts | 18 +- .../audiobookshelf-normalizer.test.ts | 32 + .../audiobookshelf-normalizer.ts | 21 +- .../audiobookshelf-source.types.ts | 7 + .../adapters/source-adapter.types.ts | 4 + .../migration/executor/executor-utils.test.ts | 34 + .../migration/executor/executor-utils.ts | 11 + .../executor/migration-import.repository.ts | 26 +- .../audiobook-chapter-revision.e2e-spec.ts | 407 + .../test/audiobook-chapter-write.e2e-spec.ts | 452 + server/test/chapter-health.e2e-spec.ts | 699 + .../route-inventory/manifest.json | 6 +- .../route-inventory/routes-02.json | 132 + .../route-inventory/routes-06.json | 84 + .../metadata-lock/metadata-lock-harness.ts | 3 + ...igration-audiobookshelf-fixture-builder.ts | 19 + server/test/metadata-lock.e2e-spec.ts | 26 +- .../test/migration-audiobookshelf.e2e-spec.ts | 13 + 180 files changed, 48279 insertions(+), 299 deletions(-) create mode 100644 client/src/features/book/components/detail/chapters/ChapterCutCard.vue create mode 100644 client/src/features/book/components/detail/chapters/ChapterHistoryMenu.vue create mode 100644 client/src/features/book/components/detail/chapters/ChapterListCard.vue create mode 100644 client/src/features/book/components/detail/chapters/ChapterListenBand.vue create mode 100644 client/src/features/book/components/detail/chapters/ChapterSaveBar.vue create mode 100644 client/src/features/book/components/detail/chapters/ChapterSnapDialog.vue create mode 100644 client/src/features/book/components/detail/chapters/ChapterSourceSheet.vue create mode 100644 client/src/features/book/components/detail/chapters/ChapterTimeline.vue create mode 100644 client/src/features/book/components/detail/chapters/ChapterToolDialog.vue create mode 100644 client/src/features/book/components/detail/chapters/ChapterWaveformStrip.vue create mode 100644 client/src/features/book/components/detail/chapters/ChaptersHeroBar.vue create mode 100644 client/src/features/book/components/detail/chapters/__tests__/ChapterCutCard.spec.ts create mode 100644 client/src/features/book/components/detail/chapters/__tests__/ChapterListCard.spec.ts create mode 100644 client/src/features/book/components/detail/chapters/__tests__/ChapterSaveBar.spec.ts create mode 100644 client/src/features/book/components/detail/chapters/__tests__/ChapterSnapDialog.spec.ts create mode 100644 client/src/features/book/components/detail/chapters/__tests__/ChapterSourceSheet.spec.ts create mode 100644 client/src/features/book/components/detail/chapters/__tests__/ChapterTimeline.spec.ts create mode 100644 client/src/features/book/components/detail/chapters/__tests__/ChapterToolDialog.spec.ts create mode 100644 client/src/features/book/components/detail/chapters/__tests__/ChapterWaveformStrip.spec.ts create mode 100644 client/src/features/book/components/detail/chapters/__tests__/ChaptersHeroBar.spec.ts create mode 100644 client/src/features/book/components/detail/tabs/ChaptersTab.vue create mode 100644 client/src/features/book/composables/__tests__/useBookChapters.spec.ts create mode 100644 client/src/features/book/composables/__tests__/useChapterDraft.spec.ts create mode 100644 client/src/features/book/composables/__tests__/useChapterPreview.spec.ts create mode 100644 client/src/features/book/composables/__tests__/useChapterReview.spec.ts create mode 100644 client/src/features/book/composables/__tests__/useChapterWaveform.spec.ts create mode 100644 client/src/features/book/composables/useBookChapters.ts create mode 100644 client/src/features/book/composables/useChapterDraft.ts create mode 100644 client/src/features/book/composables/useChapterPreview.ts create mode 100644 client/src/features/book/composables/useChapterReview.ts create mode 100644 client/src/features/book/composables/useChapterWaveform.ts create mode 100644 client/src/features/book/lib/__tests__/chapter-align.spec.ts create mode 100644 client/src/features/book/lib/__tests__/chapter-edit.spec.ts create mode 100644 client/src/features/book/lib/__tests__/chapter-io.spec.ts create mode 100644 client/src/features/book/lib/__tests__/chapter-snap.spec.ts create mode 100644 client/src/features/book/lib/__tests__/chapter-time.spec.ts create mode 100644 client/src/features/book/lib/chapter-align.ts create mode 100644 client/src/features/book/lib/chapter-edit.ts create mode 100644 client/src/features/book/lib/chapter-io.ts create mode 100644 client/src/features/book/lib/chapter-snap.ts create mode 100644 client/src/features/book/lib/chapter-time.ts create mode 100644 client/src/features/tools/api/chapter-health.ts create mode 100644 client/src/features/tools/chapter-health/components/ChapterHealthCategoryTabs.vue create mode 100644 client/src/features/tools/chapter-health/components/ChapterHealthLookupPanel.vue create mode 100644 client/src/features/tools/chapter-health/components/ChapterHealthRow.vue create mode 100644 client/src/features/tools/chapter-health/components/__tests__/ChapterHealthRow.spec.ts create mode 100644 client/src/features/tools/chapter-health/views/ChapterHealthView.spec.ts create mode 100644 client/src/features/tools/chapter-health/views/ChapterHealthView.vue create mode 100644 client/src/features/tools/composables/__tests__/useChapterHealth.spec.ts create mode 100644 client/src/features/tools/composables/useChapterHealth.ts create mode 100644 packages/types/src/chapter-health.ts create mode 100644 server/src/common/services/file-lock.service.ts create mode 100644 server/src/common/utils/audiobook-chapters.utils.test.ts create mode 100644 server/src/common/utils/audiobook-chapters.utils.ts create mode 100644 server/src/common/utils/request-abort.utils.ts create mode 100644 server/src/db/migrations/0127_add_audiobook_chapter_editor.sql create mode 100644 server/src/db/migrations/meta/0127_snapshot.json create mode 100644 server/src/db/preserve-audiobook-revision.ts create mode 100644 server/src/modules/audiobook-chapters/audiobook-chapters.module.ts create mode 100644 server/src/modules/audiobook-chapters/audiobook-chapters.repository.ts create mode 100644 server/src/modules/audiobook-chapters/audiobook-chapters.service.test.ts create mode 100644 server/src/modules/audiobook-chapters/audiobook-chapters.service.ts create mode 100644 server/src/modules/chapter-editor/chapter-auto-apply.test.ts create mode 100644 server/src/modules/chapter-editor/chapter-auto-apply.ts create mode 100644 server/src/modules/chapter-editor/chapter-editor.controller.ts create mode 100644 server/src/modules/chapter-editor/chapter-editor.module.ts create mode 100644 server/src/modules/chapter-editor/chapter-editor.repository.ts create mode 100644 server/src/modules/chapter-editor/chapter-editor.service.test.ts create mode 100644 server/src/modules/chapter-editor/chapter-editor.service.ts create mode 100644 server/src/modules/chapter-editor/chapter-job.repository.ts create mode 100644 server/src/modules/chapter-editor/chapter-job.service.test.ts create mode 100644 server/src/modules/chapter-editor/chapter-job.service.ts create mode 100644 server/src/modules/chapter-editor/chapter-lookup.service.test.ts create mode 100644 server/src/modules/chapter-editor/chapter-lookup.service.ts create mode 100644 server/src/modules/chapter-editor/chapter-pause.service.test.ts create mode 100644 server/src/modules/chapter-editor/chapter-pause.service.ts create mode 100644 server/src/modules/chapter-editor/dto/analyze-book-chapters.dto.ts create mode 100644 server/src/modules/chapter-editor/dto/ebook-book-chapters.dto.ts create mode 100644 server/src/modules/chapter-editor/dto/lookup-book-chapters.dto.ts create mode 100644 server/src/modules/chapter-editor/dto/revert-book-chapters.dto.ts create mode 100644 server/src/modules/chapter-editor/dto/save-book-chapters.dto.ts create mode 100644 server/src/modules/chapter-editor/dto/start-chapter-job.dto.ts create mode 100644 server/src/modules/chapter-editor/dto/waveform-book-chapters.dto.ts create mode 100644 server/src/modules/chapter-editor/dto/write-book-chapters.dto.ts create mode 100644 server/src/modules/chapter-health/chapter-health.controller.test.ts create mode 100644 server/src/modules/chapter-health/chapter-health.controller.ts create mode 100644 server/src/modules/chapter-health/chapter-health.module.ts create mode 100644 server/src/modules/chapter-health/chapter-health.repository.ts create mode 100644 server/src/modules/chapter-health/chapter-health.service.test.ts create mode 100644 server/src/modules/chapter-health/chapter-health.service.ts create mode 100644 server/src/modules/chapter-health/chapter-lookup.worker.test.ts create mode 100644 server/src/modules/chapter-health/chapter-lookup.worker.ts create mode 100644 server/src/modules/chapter-health/dto/chapter-health.dto.ts create mode 100644 server/test/audiobook-chapter-revision.e2e-spec.ts create mode 100644 server/test/audiobook-chapter-write.e2e-spec.ts create mode 100644 server/test/chapter-health.e2e-spec.ts diff --git a/client/src/composables/__tests__/useSidebarNav.spec.ts b/client/src/composables/__tests__/useSidebarNav.spec.ts index 2df53840f..8b8b61e1e 100644 --- a/client/src/composables/__tests__/useSidebarNav.spec.ts +++ b/client/src/composables/__tests__/useSidebarNav.spec.ts @@ -122,7 +122,7 @@ describe('sidebar nav registry', () => { expect(resolved.isActive).toBe(true) }) - it.each(['manage_libraries', 'library_delete_books'])('shows Tools for the any-of permission %s', (permission) => { + it.each(['manage_libraries', 'library_delete_books', 'library_edit_metadata'])('shows Tools for the any-of permission %s', (permission) => { expect(allowedIds(makeContext({ permissions: [permission] }))).toContain('tools') }) @@ -142,8 +142,25 @@ describe('sidebar nav registry', () => { expect(resolved.to).toEqual({ name: 'tools-duplicate-books' }) }) + it('resolves Tools to chapter health when the user can only edit metadata', () => { + const context = makeContext({ permissions: ['library_edit_metadata'] }) + + const resolved = resolveNavEntry(entry('tools'), context, makeRoute('dashboard'), 'Tools') + + expect(resolved.to).toEqual({ name: 'tools-chapter-health' }) + }) + + it('prefers duplicate books over chapter health when the user can delete books too', () => { + const context = makeContext({ permissions: ['library_delete_books', 'library_edit_metadata'] }) + + const resolved = resolveNavEntry(entry('tools'), context, makeRoute('dashboard'), 'Tools') + + expect(resolved.to).toEqual({ name: 'tools-duplicate-books' }) + }) + it('marks Tools active on any tools- route', () => { expect(entry('tools').isActive(makeRoute('tools-bulk-rename'))).toBe(true) + expect(entry('tools').isActive(makeRoute('tools-chapter-health'))).toBe(true) expect(entry('tools').isActive(makeRoute('dashboard'))).toBe(false) }) diff --git a/client/src/composables/useSidebarNav.ts b/client/src/composables/useSidebarNav.ts index 9fbc2188b..d5aa1b7f4 100644 --- a/client/src/composables/useSidebarNav.ts +++ b/client/src/composables/useSidebarNav.ts @@ -155,9 +155,15 @@ export const SIDEBAR_NAV_REGISTRY: readonly SidebarNavEntry[] = [ icon: Wrench, zone: 'primary', modes: ['books'], - to: (context) => ({ name: context.hasPermission('manage_libraries') ? 'tools-entity-manager' : 'tools-duplicate-books' }), + to: (context) => ({ + name: context.hasPermission('manage_libraries') + ? 'tools-entity-manager' + : context.hasPermission('library_delete_books') + ? 'tools-duplicate-books' + : 'tools-chapter-health', + }), isActive: (route) => routeNameStartsWith(route, 'tools-'), - permission: ['manage_libraries', 'library_delete_books'], + permission: ['manage_libraries', 'library_delete_books', 'library_edit_metadata'], }, { id: 'podcast-queue', diff --git a/client/src/features/book/components/detail/BookDetailHeader.vue b/client/src/features/book/components/detail/BookDetailHeader.vue index 41b09e00c..d4f0db3c5 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; parentLibrary?: { id: number; name: string } | null }>() +const props = defineProps<{ bookId: number; canEditMetadata?: boolean; hasAudio?: boolean; parentLibrary?: { id: number; name: string } | null }>() const { t } = useI18n() const route = useRoute() @@ -48,7 +48,7 @@ async function navigateToBook(id: number) {