From e0d3d42150080444a326a26ea46884359d423355 Mon Sep 17 00:00:00 2001
From: neon <18559658+neonsolstice@users.noreply.github.com>
Date: Sat, 19 Sep 2026 02:02:24 +0000
Subject: [PATCH] feat: add read-aloud and media playback support
Add EPUB narration, shared media controls, and native device session integration.
Apply the generated migrations and update all first-party clients alongside the server.
BREAKING CHANGE: introduce incompatible database and API contracts for media playback
---
.env.example | 3 +
README.md | 3 +
client/eslint.config.ts | 11 +-
client/public/assets/foliate/overlayer.d.ts | 10 +
client/public/assets/foliate/text-walker.d.ts | 1 +
client/public/assets/foliate/tts.d.ts | 9 +
client/src/App.vue | 59 +-
client/src/assets/theme/bridge.css | 1 +
client/src/assets/theme/tokens.css | 16 +
client/src/components/AppHeader.vue | 25 +-
client/src/components/AppLayout.vue | 11 +-
client/src/components/AppSidebar.vue | 338 +-
client/src/components/ChipGroup.vue | 21 +
client/src/components/EmptyState.vue | 18 +
client/src/components/FilterChip.vue | 27 +
client/src/components/FormSheet.vue | 140 +
client/src/components/ListEndFooter.vue | 22 +
client/src/components/ListSkeleton.vue | 16 +
client/src/components/LoadErrorPanel.vue | 21 +
client/src/components/SelectionActionBar.vue | 8 +-
client/src/components/ViewHeader.vue | 2 +-
.../components/__tests__/EmptyState.spec.ts | 27 +
.../components/__tests__/FormSheet.spec.ts | 106 +
.../entity-index/EntityIndexView.vue | 20 +-
.../__tests__/EntityIndexView.spec.ts | 68 +
client/src/components/media/MediaScrubber.vue | 237 +
.../src/components/media/MediaTransport.vue | 124 +
.../components/media/MediaVolumeControl.vue | 93 +
.../media/__tests__/MediaScrubber.spec.ts | 201 +
.../media/__tests__/MediaTransport.spec.ts | 92 +
.../__tests__/MediaVolumeControl.spec.ts | 74 +
client/src/components/media/types.ts | 25 +
.../sidebar/SidebarEntitySection.vue | 35 +-
.../components/sidebar/SidebarModeSwitch.vue | 72 +
.../__tests__/SidebarEntitySection.spec.ts | 21 +
.../sidebar/__tests__/SidebarZone.spec.ts | 3 +
.../sidebar/__tests__/sidebar-order.spec.ts | 69 +
.../src/components/sidebar/sidebar-order.ts | 30 +
client/src/components/ui/Chip.vue | 53 +
client/src/components/ui/ConfirmDialog.vue | 26 +-
.../ui/__tests__/ConfirmDialog.spec.ts | 81 +
client/src/components/ui/input/Input.vue | 24 +-
client/src/components/ui/select/Select.vue | 38 +
client/src/components/ui/select/index.ts | 1 +
.../src/components/ui/textarea/Textarea.vue | 36 +
client/src/components/ui/textarea/index.ts | 1 +
.../view-header/ViewHeaderDisplayControls.vue | 6 +
.../ViewHeaderMobileSearchSheet.vue | 4 +-
.../__tests__/useMediaMode.spec.ts | 116 +
.../__tests__/useSidebarNav.spec.ts | 38 +-
.../__tests__/useSidebarPrefs.spec.ts | 6 +
client/src/composables/useDraggableOrder.ts | 6 +-
client/src/composables/useFilePicker.ts | 23 +
.../composables/useInfiniteScrollSentinel.ts | 0
client/src/composables/useMediaMode.ts | 100 +
client/src/composables/useModal.ts | 30 +-
.../src/composables/usePagedEntityMembers.ts | 117 +
client/src/composables/useScrollOverflow.ts | 54 +
client/src/composables/useSidebarNav.ts | 39 +-
client/src/composables/useSidebarPrefs.ts | 6 +-
.../admin/SharedReadingInsightsPage.vue | 2 +-
.../SharedReadingInsightsPage.spec.ts | 7 +-
.../lib/__tests__/permission-presets.spec.ts | 15 +-
.../features/admin/lib/permission-presets.ts | 12 +-
client/src/features/audit/AuditLogPage.vue | 11 +-
.../audit/__tests__/audit-display.spec.ts | 1 +
client/src/features/audit/audit-display.ts | 2 +-
client/src/features/audit/audit-resources.ts | 2 +
.../composables/__tests__/useAuth.spec.ts | 16 +
.../src/features/auth/composables/useAuth.ts | 14 +-
.../__tests__/BookDockFileRow.spec.ts | 2 +-
.../BookMetadataFetchWidget.spec.ts | 3 +-
.../ReleaseFileInspectionPanel.spec.ts | 2 +-
.../book/components/BookCoverCard.vue | 23 +-
.../book/components/BookDownloadButton.vue | 42 +-
.../features/book/components/BookListRow.vue | 35 +-
.../book/components/BookQuickView.vue | 29 +-
.../book/components/BookSortBuilder.vue | 5 +-
.../__tests__/BookCoverCard.spec.ts | 14 +-
.../components/__tests__/BookListRow.spec.ts | 17 +
.../__tests__/BookQuickView.spec.ts | 11 +
.../__tests__/BookTableCells.spec.ts | 8 +
.../components/__tests__/DetailsTab.spec.ts | 60 +
.../detail/reading-log/ReadingLogLedger.vue | 2 +
.../reading-log/ReadingLogSourceSplit.vue | 2 +
.../reading-log/ReadingLogTraceChart.vue | 2 +
.../__tests__/ReadingLogSourceSplit.spec.ts | 8 +-
.../components/detail/tabs/DetailsTab.vue | 54 +-
.../detail/tabs/__tests__/DetailsTab.spec.ts | 11 +
.../tabs/__tests__/DetailsTabActions.spec.ts | 11 +
.../tabs/__tests__/ReadingLogTab.spec.ts | 37 +-
.../components/table/BookTableFormatCell.vue | 32 +-
.../__tests__/useBookDownload.spec.ts | 13 +-
.../__tests__/useMetadataEditor.spec.ts | 11 +
.../__tests__/useMetadataLocks.spec.ts | 11 +
.../__tests__/usePersonalNote.spec.ts | 11 +
.../book/composables/useBookDownload.ts | 13 +-
.../metadata-refresh-feedback.spec.ts | 11 +
.../book/lib/book-card-mapper.test.ts | 29 +
.../src/features/book/lib/book-card-mapper.ts | 10 +-
.../features/book/lib/file-capabilities.ts | 18 +
.../components/AddToCollectionSheet.vue | 40 +-
.../components/CollectionMembershipList.vue | 71 +
.../components/CreateCollectionDialog.vue | 13 +-
.../__tests__/AddToCollectionSheet.spec.ts | 2 +
.../__tests__/CreateCollectionDialog.spec.ts | 113 +
.../__tests__/useCollections.spec.ts | 59 +
.../collection/composables/useCollections.ts | 141 +-
.../collection/lib/collection-access.spec.ts | 19 +-
.../collection/lib/collection-access.ts | 8 -
.../collection/views/CollectionsView.vue | 4 +-
.../DashboardPodcastScroller.spec.ts | 151 +
.../components/DashboardPodcastScroller.vue | 171 +
.../components/DashboardScroller.spec.ts | 6 +-
.../components/DashboardScroller.vue | 4 +-
.../components/DashboardSettingsSheet.spec.ts | 10 +-
.../components/DashboardSettingsSheet.vue | 3 +-
.../__tests__/CurrentlyReadingWidget.spec.ts | 14 +-
.../__tests__/useDashboardConfig.spec.ts | 4 +-
.../__tests__/useDashboardLabels.spec.ts | 1 +
.../__tests__/useDashboardScroller.spec.ts | 4 +-
.../composables/useDashboardConfig.ts | 14 +-
.../composables/useDashboardLabels.ts | 1 +
.../useDashboardPodcastScroller.ts | 35 +
.../composables/useDashboardScroller.ts | 6 +-
.../components/LibraryCreatorDetails.vue | 45 +-
.../components/LibraryCreatorFolders.vue | 47 +-
.../components/LibraryCreatorLocalFolders.vue | 134 +
.../components/LibraryCreatorModal.vue | 66 +-
.../components/LibraryCreatorSchedule.vue | 14 +-
.../__tests__/LibraryCreatorFolders.spec.ts | 16 +
.../__tests__/LibraryCreatorModal.spec.ts | 30 +-
.../__tests__/useLibraries.spec.ts | 1 +
.../__tests__/useLibraryCreator.spec.ts | 37 +-
.../library/composables/useLibraries.ts | 53 +-
.../composables/useLibraryCreationRedirect.ts | 4 +-
.../library/composables/useLibraryCreator.ts | 44 +-
.../features/library/views/LibrariesView.vue | 7 +-
.../lib/notification-category-groups.ts | 20 +-
.../AddShowToCollectionSheet.spec.ts | 181 +
.../components/AddShowToCollectionSheet.vue | 198 +
.../PodcastAcquisitionField.spec.ts | 34 +
.../components/PodcastAcquisitionField.vue | 42 +
.../components/PodcastAddFeedSheet.spec.ts | 83 +
.../components/PodcastAddFeedSheet.vue | 175 +
.../podcast/components/PodcastArtwork.vue | 42 +
.../components/PodcastBookmarksPanel.spec.ts | 34 +
.../components/PodcastBookmarksPanel.vue | 204 +
.../components/PodcastDownloadWidget.spec.ts | 90 +
.../components/PodcastDownloadWidget.vue | 249 +
.../PodcastEditDetailsSheet.spec.ts | 224 +
.../components/PodcastEditDetailsSheet.vue | 339 +
.../PodcastEpisodeEditSheet.spec.ts | 180 +
.../components/PodcastEpisodeEditSheet.vue | 283 +
.../PodcastEpisodeQuickView.spec.ts | 155 +
.../components/PodcastEpisodeQuickView.vue | 224 +
.../PodcastEpisodeResultsPanel.spec.ts | 98 +
.../components/PodcastEpisodeResultsPanel.vue | 121 +
.../components/PodcastEpisodeRow.spec.ts | 232 +
.../podcast/components/PodcastEpisodeRow.vue | 317 +
.../components/PodcastEpisodeStatusChips.vue | 26 +
.../podcast/components/PodcastFieldLabel.vue | 61 +
.../components/PodcastHealthPanel.spec.ts | 61 +
.../podcast/components/PodcastHealthPanel.vue | 97 +
.../podcast/components/PodcastImportGroup.vue | 42 +
.../components/PodcastImportSheet.spec.ts | 280 +
.../podcast/components/PodcastImportSheet.vue | 327 +
.../podcast/components/PodcastLiveRegion.vue | 9 +
.../components/PodcastMiniPlayer.spec.ts | 268 +
.../podcast/components/PodcastMiniPlayer.vue | 436 +
.../components/PodcastOpmlImportSheet.spec.ts | 60 +
.../components/PodcastOpmlImportSheet.vue | 72 +
.../components/PodcastPlaylistEditor.spec.ts | 198 +
.../components/PodcastPlaylistEditor.vue | 398 +
.../components/PodcastPlaylistRuleSummary.vue | 71 +
.../components/PodcastQueueList.spec.ts | 132 +
.../podcast/components/PodcastQueueList.vue | 222 +
.../components/PodcastQueuePanel.spec.ts | 158 +
.../podcast/components/PodcastQueuePanel.vue | 310 +
.../components/PodcastShortcutsDialog.vue | 57 +
.../components/PodcastShowCard.spec.ts | 139 +
.../podcast/components/PodcastShowCard.vue | 191 +
.../components/PodcastShowManageSheet.spec.ts | 71 +
.../components/PodcastShowManageSheet.vue | 159 +
.../PodcastShowSettingsSheet.spec.ts | 103 +
.../components/PodcastShowSettingsSheet.vue | 110 +
.../components/PodcastShowsPanel.spec.ts | 65 +
.../podcast/components/PodcastShowsPanel.vue | 106 +
.../components/PodcastStatusChip.spec.ts | 32 +
.../podcast/components/PodcastStatusChip.vue | 67 +
.../podcast/components/PodcastViewHeader.vue | 287 +
.../composables/createLockedMetadataForm.ts | 130 +
.../composables/podcast-audio-engine.spec.ts | 182 +
.../composables/podcast-audio-engine.ts | 224 +
.../podcast/composables/podcast-list-lane.ts | 71 +
.../composables/podcast-media-session.spec.ts | 147 +
.../composables/podcast-media-session.ts | 110 +
.../composables/podcast-player-actions.ts | 123 +
.../podcast-player-persistence.spec.ts | 131 +
.../composables/podcast-player-persistence.ts | 152 +
.../composables/podcast-player-preferences.ts | 155 +
.../composables/podcast-player-queue.ts | 106 +
.../composables/podcast-player-state.spec.ts | 78 +
.../composables/podcast-player-state.ts | 72 +
.../composables/podcast-sleep-timer.spec.ts | 88 +
.../composables/podcast-sleep-timer.ts | 114 +
.../composables/useEpisodeQuickView.ts | 33 +
.../composables/usePodcastAnnouncer.spec.ts | 49 +
.../composables/usePodcastAnnouncer.ts | 30 +
.../usePodcastArtworkEditor.spec.ts | 104 +
.../composables/usePodcastArtworkEditor.ts | 91 +
.../composables/usePodcastBulkDelete.spec.ts | 116 +
.../composables/usePodcastBulkDelete.ts | 95 +
.../usePodcastDownloadBatches.spec.ts | 211 +
.../composables/usePodcastDownloadBatches.ts | 224 +
.../composables/usePodcastEpisodeDetail.ts | 57 +
.../usePodcastEpisodeListActions.spec.ts | 143 +
.../usePodcastEpisodeListActions.ts | 158 +
.../usePodcastEpisodeMetadataEditor.spec.ts | 187 +
.../usePodcastEpisodeMetadataEditor.ts | 148 +
.../composables/usePodcastEvents.spec.ts | 133 +
.../podcast/composables/usePodcastEvents.ts | 115 +
.../usePodcastFailingFeedCount.spec.ts | 96 +
.../composables/usePodcastFailingFeedCount.ts | 50 +
.../composables/usePodcastFeedAdd.spec.ts | 144 +
.../podcast/composables/usePodcastFeedAdd.ts | 185 +
.../composables/usePodcastFeedHealth.spec.ts | 90 +
.../composables/usePodcastFeedHealth.ts | 72 +
.../composables/usePodcastImport.spec.ts | 80 +
.../podcast/composables/usePodcastImport.ts | 215 +
.../usePodcastImportProgress.spec.ts | 96 +
.../composables/usePodcastImportProgress.ts | 106 +
.../usePodcastKeyboardShortcuts.spec.ts | 180 +
.../usePodcastKeyboardShortcuts.ts | 90 +
.../composables/usePodcastLibraryAccess.ts | 27 +
.../composables/usePodcastLibraryPage.ts | 267 +
.../usePodcastMetadataEditor.spec.ts | 148 +
.../composables/usePodcastMetadataEditor.ts | 80 +
.../usePodcastOpmlTransfer.spec.ts | 82 +
.../composables/usePodcastOpmlTransfer.ts | 93 +
.../composables/usePodcastPlayer.spec.ts | 810 +
.../podcast/composables/usePodcastPlayer.ts | 605 +
.../composables/usePodcastPlayerDisplay.ts | 84 +
.../usePodcastPlaylistPreview.spec.ts | 146 +
.../composables/usePodcastPlaylistPreview.ts | 105 +
.../composables/usePodcastPlaylists.spec.ts | 139 +
.../composables/usePodcastPlaylists.ts | 133 +
.../composables/usePodcastQueue.spec.ts | 121 +
.../podcast/composables/usePodcastQueue.ts | 107 +
.../composables/usePodcastQueueMaintenance.ts | 98 +
.../composables/usePodcastSettings.spec.ts | 139 +
.../podcast/composables/usePodcastSettings.ts | 173 +
.../composables/usePodcastShortcuts.ts | 53 +
.../usePodcastShowManagement.spec.ts | 107 +
.../composables/usePodcastShowManagement.ts | 458 +
.../composables/usePodcastShowPage.spec.ts | 129 +
.../podcast/composables/usePodcastShowPage.ts | 297 +
.../usePodcastShowSelection.spec.ts | 74 +
.../composables/usePodcastShowSelection.ts | 50 +
.../podcast/composables/usePodcasts.spec.ts | 344 +
.../podcast/composables/usePodcasts.ts | 337 +
.../features/podcast/lib/podcast-bulk.spec.ts | 45 +
.../src/features/podcast/lib/podcast-bulk.ts | 26 +
.../podcast/lib/podcast-episode-api.spec.ts | 51 +
.../podcast/lib/podcast-episode-api.ts | 81 +
.../podcast/lib/podcast-episode-filters.ts | 28 +
.../lib/podcast-episode-presentation.ts | 50 +
.../podcast/lib/podcast-format.spec.ts | 71 +
.../features/podcast/lib/podcast-format.ts | 33 +
.../src/features/podcast/lib/podcast-locks.ts | 10 +
.../podcast/lib/podcast-playback-rates.ts | 23 +
.../lib/podcast-player-visibility.spec.ts | 27 +
.../podcast/lib/podcast-player-visibility.ts | 9 +
.../lib/podcast-playlist-rules.spec.ts | 35 +
.../podcast/lib/podcast-playlist-rules.ts | 42 +
.../podcast/test/audio-engine-stub.ts | 145 +
.../podcast/test/episode-action-stubs.ts | 31 +
client/src/features/podcast/test/fixtures.ts | 144 +
client/src/features/podcast/test/stubs.ts | 400 +
.../views/PodcastCollectionView.spec.ts | 257 +
.../podcast/views/PodcastCollectionView.vue | 129 +
.../podcast/views/PodcastLibrariesView.vue | 57 +
.../podcast/views/PodcastLibraryView.spec.ts | 714 +
.../podcast/views/PodcastLibraryView.vue | 825 +
.../podcast/views/PodcastPlayerView.spec.ts | 259 +
.../podcast/views/PodcastPlayerView.vue | 941 +
.../podcast/views/PodcastScopeView.spec.ts | 417 +
.../podcast/views/PodcastScopeView.vue | 218 +
.../podcast/views/PodcastShowView.spec.ts | 656 +
.../podcast/views/PodcastShowView.vue | 714 +
client/src/features/reader/ReaderView.vue | 992 +-
.../features/reader/cbz/CbzReaderView.spec.ts | 1 -
.../src/features/reader/cbz/CbzReaderView.vue | 35 +-
.../reader/epub/components/ReaderFooter.vue | 26 +-
.../reader/epub/components/ReaderHeader.vue | 61 +
.../epub/components/ReaderSearchPanel.vue | 11 +-
.../reader/epub/components/ReaderSidebar.vue | 34 +-
.../reader/epub/components/SelectionPopup.vue | 16 +-
.../components/__tests__/ReaderFooter.spec.ts | 25 +
.../components/__tests__/ReaderHeader.spec.ts | 19 +
.../__tests__/ReaderSearchPanel.spec.ts | 21 +
.../__tests__/ReaderSidebar.spec.ts | 23 +
.../__tests__/SelectionPopup.spec.ts | 5 +
.../composables/__tests__/useFoliate.spec.ts | 16 +
.../__tests__/useFoliateInput.spec.ts | 107 +
.../__tests__/useFoliateSelection.spec.ts | 5 +-
.../__tests__/useReaderSelection.spec.ts | 2 +
.../reader/epub/composables/useFoliate.ts | 91 +-
.../epub/composables/useFoliateInput.ts | 92 +-
.../epub/composables/useFoliateSelection.ts | 3 +-
.../epub/composables/useReaderSelection.ts | 4 +-
.../components/MediaOverlayMiniPlayer.vue | 102 +
.../composables/useMediaOverlay.test.ts | 132 +
.../composables/useMediaOverlay.ts | 169 +
.../lib/media-overlay-resume.test.ts | 101 +
.../media-overlay/lib/media-overlay-resume.ts | 76 +
.../__tests__/useVisibility.spec.ts | 81 +
.../composables/useReaderProgress.test.ts | 54 +
.../shared/composables/useReaderProgress.ts | 54 +-
.../shared/composables/useVisibility.ts | 98 +-
.../features/settings/LibrariesSettings.vue | 18 +-
.../src/features/settings/PodcastSettings.vue | 232 +
.../__tests__/LibrariesSettings.spec.ts | 50 +-
.../settings/__tests__/admin-tabs.spec.ts | 17 +-
.../settings/components/SettingsTabs.spec.ts | 2 +-
.../settings/components/SettingsTabs.vue | 75 +-
.../src/features/settings/lib/admin-tabs.ts | 6 +-
.../src/features/settings/lib/settings-nav.ts | 31 +-
.../__tests__/library-ledger-helpers.spec.ts | 3 +-
.../components/LibraryAutoFetchConfig.spec.ts | 1 +
.../components/CreateSmartScopeDialog.vue | 27 +-
.../components/SmartScopeEditorPanel.vue | 3 +-
.../__tests__/CreateSmartScopeDialog.spec.ts | 68 +-
.../__tests__/SmartScopeEditorPanel.spec.ts | 2 +
.../__tests__/useSmartScopeKoboSync.spec.ts | 2 +
.../__tests__/useSmartScopes.spec.ts | 50 +-
.../smart-scope/composables/useSmartScopes.ts | 127 +-
.../smart-scope/views/SmartScopesView.vue | 4 +-
.../user/SourceDistributionChart.vue | 2 +-
.../__tests__/SourceDistributionChart.spec.ts | 8 +-
.../features/statistics/lib/breakdown.test.ts | 6 +-
.../lib/source-bucket-colors.test.ts | 10 +-
.../statistics/lib/source-bucket-colors.ts | 8 +-
.../src/features/tts/TtsAdminSettings.test.ts | 223 +
client/src/features/tts/TtsAdminSettings.vue | 272 +
.../tts/TtsOpenAiVoiceCuration.test.ts | 287 +
.../features/tts/TtsOpenAiVoiceCuration.vue | 340 +
client/src/features/tts/TtsSettings.vue | 131 +
client/src/features/tts/api/tts.api.ts | 222 +
.../tts/components/TtsAdminProviderCard.vue | 275 +
.../tts/components/TtsAdminProviderForm.vue | 106 +
.../tts/components/TtsAdminVoiceChips.vue | 121 +
.../features/tts/components/TtsMiniPlayer.vue | 279 +
.../tts/components/TtsMiniPlayerExpanded.vue | 296 +
.../tts/components/TtsReaderControls.vue | 34 +
.../tts/components/TtsResumePrompt.vue | 101 +
.../tts/components/TtsSleepTimerPicker.vue | 46 +
.../tts/components/TtsSpeedControl.vue | 37 +
.../tts/components/TtsVoicePicker.vue | 217 +
.../tts/composables/useFoliateTts.test.ts | 227 +
.../features/tts/composables/useFoliateTts.ts | 277 +
.../tts/composables/useTtsAdminProviders.ts | 230 +
.../tts/composables/useTtsKeyboard.ts | 44 +
.../tts/composables/useTtsMediaSession.ts | 44 +
.../tts/composables/useTtsMiniPlayerUi.ts | 36 +
.../tts/composables/useTtsPlayer.test.ts | 553 +
.../features/tts/composables/useTtsPlayer.ts | 445 +
.../tts/composables/useTtsPosition.ts | 108 +
.../tts/composables/useTtsPreferences.test.ts | 146 +
.../tts/composables/useTtsPreferences.ts | 76 +
.../tts/composables/useTtsReadingSession.ts | 61 +
.../tts/composables/useTtsSleepTimer.test.ts | 93 +
.../tts/composables/useTtsSleepTimer.ts | 41 +
.../tts/composables/useTtsVoices.test.ts | 157 +
.../features/tts/composables/useTtsVoices.ts | 88 +
client/src/features/tts/lib/tts-state.ts | 19 +
.../features/tts/lib/voice-display.test.ts | 30 +
client/src/features/tts/lib/voice-display.ts | 46 +
.../features/tts/lib/voice-presets.test.ts | 182 +
client/src/features/tts/lib/voice-presets.ts | 78 +
client/src/i18n/formatters.spec.ts | 9 +-
client/src/i18n/formatters.ts | 22 +
client/src/lib/__tests__/api-json.spec.ts | 74 +
client/src/lib/__tests__/audio-focus.spec.ts | 47 +
client/src/lib/__tests__/entity-count.spec.ts | 36 +
client/src/lib/api-json.ts | 73 +
client/src/lib/api.ts | 27 +-
client/src/lib/async.ts | 54 +
client/src/lib/audio-focus.ts | 23 +
client/src/lib/entity-count.ts | 27 +
client/src/lib/formatting.ts | 22 +-
client/src/locales/cs.json | 99 +-
client/src/locales/da.json | 34 +-
client/src/locales/de.json | 490 +-
client/src/locales/en.json | 1044 +-
client/src/locales/es.json | 87 +
client/src/locales/fi.json | 34 +-
client/src/locales/fr.json | 84 +
client/src/locales/it.json | 37 +-
client/src/locales/nl.json | 732 +-
client/src/locales/pl.json | 37 +-
client/src/locales/pt.json | 47 +-
client/src/locales/ru.json | 34 +-
client/src/locales/sl.json | 1031 +-
client/src/locales/sv.json | 34 +-
client/src/locales/uk.json | 33 +-
client/src/locales/zh.json | 77 +
.../router/__tests__/podcast-feature.spec.ts | 21 +
client/src/router/index.ts | 75 +-
client/src/test-setup.ts | 18 +
client/src/views/CollectionView.vue | 2 +-
client/src/views/DashboardView.vue | 48 +-
client/src/views/HomeView.vue | 2 +-
client/src/views/SmartScopeView.vue | 2 +-
.../views/__tests__/BookDetailView.spec.ts | 11 +
client/tsconfig.app.json | 5 +-
client/vite.config.ts | 6 +-
docker-compose.dev.yml | 21 +
docker-compose.yml | 25 +
docs/BOOK_REQUESTS.md | 215 -
docs/DEVELOPMENT.md | 25 +-
docs/whats-new-authoring-guide.md | 128 -
docs/whats-new-template.md | 8 -
.../bookorbit.koplugin/bookorbit_api.lua | 1 +
.../bookorbit_catalog_bulk_download.lua | 15 +-
.../bookorbit_catalog_download.lua | 12 +-
package.json | 2 +
.../reading-session-source-bucket.spec.ts | 13 +-
packages/types/src/achievement.ts | 8 +
packages/types/src/activity.ts | 202 +
packages/types/src/annotation.ts | 1 +
packages/types/src/app-features.ts | 7 +
packages/types/src/audit.ts | 14 +
packages/types/src/auth.ts | 31 +-
packages/types/src/book.ts | 26 +
packages/types/src/collection.ts | 15 +
packages/types/src/dashboard.ts | 66 +-
packages/types/src/epub.ts | 44 +
packages/types/src/index.ts | 7 +
packages/types/src/library.ts | 16 +
packages/types/src/media.ts | 17 +
packages/types/src/notification.ts | 7 +
packages/types/src/permissions.ts | 10 +
packages/types/src/podcast.ts | 964 +
packages/types/src/query.ts | 2 +
.../src/reading-session-source-bucket.ts | 12 +-
packages/types/src/reading-session.ts | 7 +-
packages/types/src/search.ts | 23 +
packages/types/src/shared-reading-insights.ts | 8 +-
packages/types/src/sidebar.ts | 19 +-
packages/types/src/smart-scope.ts | 37 +-
packages/types/src/tts.ts | 120 +
packages/types/src/upload.ts | 92 +
packages/types/src/watch-download.ts | 43 +
server/.env.example | 17 +
server/package.json | 2 +
server/src/app.module.ts | 24 +-
server/src/common/cache/stats-cache.test.ts | 45 +
server/src/common/cache/stats-cache.ts | 11 +
.../constants/app-settings.constants.ts | 1 +
.../common/constants/pagination.constants.ts | 8 +
.../src/common/constants/upload.constants.ts | 9 +
.../require-library-type.decorator.ts | 8 +
.../common/filters/http-exception.filter.ts | 4 +-
server/src/common/fs-walk.utils.test.ts | 113 +
server/src/common/fs-walk.utils.ts | 119 +
.../guards/library-access.guard.test.ts | 67 +-
.../src/common/guards/library-access.guard.ts | 32 +-
server/src/common/logger.config.ts | 8 +-
.../pipes/optional-int-query.pipe.test.ts | 36 +
.../common/pipes/optional-int-query.pipe.ts | 21 +
server/src/common/podcast-artwork-storage.ts | 24 +
.../common/podcast-feed-snapshot-storage.ts | 11 +
server/src/common/types/request-user.ts | 1 +
.../src/common/utils/bootstrap.utils.test.ts | 30 +
server/src/common/utils/bootstrap.utils.ts | 14 +
.../utils/fs-stability.utils.test.ts} | 2 +-
.../utils/fs-stability.utils.ts} | 4 +-
server/src/common/utils/fs-watch.utils.ts | 25 +
server/src/common/utils/log-url.utils.test.ts | 27 +
server/src/common/utils/log-url.utils.ts | 23 +
server/src/config/config.test.ts | 51 +-
server/src/config/config.ts | 20 +
server/src/config/env.validation.test.ts | 42 +
server/src/config/env.validation.ts | 33 +
.../0091_add_read_aloud_and_podcasts.sql | 412 +
.../0092_narration_progress_columns.sql | 4 +
.../0093_add_device_auth_sessions.sql | 23 +
.../src/db/migrations/meta/0091_snapshot.json | 21726 +++++++++++++++
.../src/db/migrations/meta/0092_snapshot.json | 21748 +++++++++++++++
.../src/db/migrations/meta/0093_snapshot.json | 21927 ++++++++++++++++
server/src/db/migrations/meta/_journal.json | 21 +
server/src/db/schema/achievements.ts | 7 +-
server/src/db/schema/auth.ts | 25 +
server/src/db/schema/books.ts | 11 +
server/src/db/schema/collections.ts | 27 +-
server/src/db/schema/index.ts | 3 +
server/src/db/schema/libraries.ts | 11 +
server/src/db/schema/oidc.ts | 4 +-
server/src/db/schema/podcasts.ts | 427 +
server/src/db/schema/reader.ts | 51 +-
server/src/db/schema/smart-scopes.ts | 24 +-
server/src/db/schema/tts.ts | 104 +
server/src/db/schema/upload-sessions.ts | 51 +
server/src/main.ts | 3 +
.../achievement/achievement-events.service.ts | 2 +
.../achievement/achievement.controller.ts | 15 +-
.../achievement.repository.test.ts | 107 +-
.../achievement/achievement.repository.ts | 130 +-
.../achievement/achievement.service.test.ts | 63 +-
.../achievement/achievement.service.ts | 97 +-
.../evaluators/devices.evaluator.test.ts | 19 +-
.../evaluators/devices.evaluator.ts | 7 -
.../achievement/seed/achievement-seed.ts | 2 +-
.../annotation/annotation.service.test.ts | 4 +-
.../annotation/dto/annotation-response.dto.ts | 2 +
.../app-settings/app-settings.module.ts | 2 +-
.../app-settings/app-settings.service.ts | 9 +-
.../auth/auth-session-transport.test.ts | 150 +
.../auth/auth-session.repository.test.ts | 134 +
.../modules/auth/auth-session.repository.ts | 193 +
.../modules/auth/auth-session.service.test.ts | 81 +
.../src/modules/auth/auth-session.service.ts | 79 +
.../src/modules/auth/auth.controller.test.ts | 8 +-
server/src/modules/auth/auth.controller.ts | 10 +-
server/src/modules/auth/auth.module.ts | 4 +
server/src/modules/auth/auth.service.test.ts | 654 +-
server/src/modules/auth/auth.service.ts | 402 +-
.../src/modules/auth/dto/auth-client.dto.ts | 13 +
server/src/modules/auth/dto/login.dto.ts | 3 +-
server/src/modules/auth/dto/logout.dto.ts | 3 +
.../src/modules/auth/dto/oidc-callback.dto.ts | 3 +-
server/src/modules/auth/dto/refresh.dto.ts | 8 +
server/src/modules/auth/jwt.strategy.test.ts | 18 +-
server/src/modules/auth/jwt.strategy.ts | 4 +-
.../oidc/backchannel-logout.service.test.ts | 262 +-
.../auth/oidc/backchannel-logout.service.ts | 40 +-
.../auth/oidc/oidc-session.repository.test.ts | 209 +-
.../auth/oidc/oidc-session.repository.ts | 81 +-
.../modules/auth/oidc/oidc.service.test.ts | 37 +-
server/src/modules/auth/oidc/oidc.service.ts | 28 +-
.../modules/auth/oidc/redirect-uri.test.ts | 62 +
server/src/modules/auth/oidc/redirect-uri.ts | 39 +
.../src/modules/auth/refresh-token.utils.ts | 11 +
.../authors/author-enrichment.gateway.test.ts | 8 +-
.../authors/author-enrichment.gateway.ts | 6 +-
.../src/modules/authors/authors.repository.ts | 41 +-
.../modules/authors/dto/list-authors.dto.ts | 2 +-
.../book-dock/book-dock-discovery.test.ts | 4 +-
.../book-dock-ingest.service.test.ts | 2 +-
.../book-dock/book-dock-ingest.service.ts | 2 +-
.../book-dock-watcher.service.test.ts | 5 +-
.../book-dock/book-dock-watcher.service.ts | 27 +-
.../book-dock/book-dock.gateway.test.ts | 8 +-
.../modules/book-dock/book-dock.gateway.ts | 6 +-
.../book-metadata-fetch.controller.ts | 5 +
.../book-metadata-fetch.gateway.test.ts | 6 +-
.../book-metadata-fetch.gateway.ts | 6 +-
.../book-request/book-request.gateway.test.ts | 4 +-
.../book-request/book-request.gateway.ts | 6 +-
.../audiobook-ebook-progress-sync.service.ts | 441 +
.../book/audioless-epub.service.test.ts | 567 +
.../modules/book/audioless-epub.service.ts | 412 +
.../book/book-access-hides-existence.test.ts | 51 +
.../book/book-query-builder.service.test.ts | 25 +-
.../book/book-query-builder.service.ts | 22 +-
.../book/book-sort-builder.service.test.ts | 17 +
.../modules/book/book-sort-builder.service.ts | 56 +-
.../src/modules/book/book.controller.test.ts | 18 +
server/src/modules/book/book.controller.ts | 88 +
server/src/modules/book/book.module.test.ts | 8 +-
server/src/modules/book/book.module.ts | 17 +-
.../src/modules/book/book.repository.test.ts | 80 +
server/src/modules/book/book.repository.ts | 326 +-
server/src/modules/book/book.service.test.ts | 152 +-
server/src/modules/book/book.service.ts | 302 +-
.../src/modules/book/dto/book-detail.dto.ts | 4 +
.../book/dto/book-dto-validation.test.ts | 15 +
.../src/modules/book/dto/save-progress.dto.ts | 23 +-
.../update-read-aloud-sync-settings.dto.ts | 8 +
.../book/dto/upsert-audio-progress.dto.ts | 16 +
.../book/reading-attempt.controller.test.ts | 18 +
.../book/reading-attempt.controller.ts | 10 +-
.../book/utils/assemble-book-cards.test.ts | 22 +
.../modules/book/utils/assemble-book-cards.ts | 40 +-
.../catalog/catalog.repository.test.ts | 1 +
.../src/modules/catalog/catalog.repository.ts | 2 +-
.../collection/collection.controller.ts | 36 +-
.../modules/collection/collection.module.ts | 3 +-
.../collection/collection.repository.test.ts | 29 +-
.../collection/collection.repository.ts | 111 +-
.../collection/collection.service.test.ts | 135 +-
.../modules/collection/collection.service.ts | 140 +-
.../collection/dto/collection-podcasts.dto.ts | 14 +
.../collection/dto/create-collection.dto.ts | 9 +-
.../dashboard-widget.calculations.ts | 59 +
.../dashboard-widget.repository.test.ts | 72 +-
.../dashboard/dashboard-widget.repository.ts | 42 +-
.../dashboard-widget.service.test.ts | 40 +
.../dashboard/dashboard-widget.service.ts | 6 +
.../dashboard/dashboard.controller.test.ts | 10 +
.../modules/dashboard/dashboard.controller.ts | 6 +
.../modules/dashboard/dashboard.repository.ts | 92 +
.../dashboard/dashboard.service.test.ts | 34 +-
.../modules/dashboard/dashboard.service.ts | 48 +-
.../dto/dashboard-scroller-batch.dto.ts | 6 +-
.../dashboard/dto/scroller-type.enum.ts | 10 +-
.../modules/email/dto/query-send-log.dto.ts | 12 +
.../email/email-send-log.controller.test.ts | 9 +-
.../email/email-send-log.controller.ts | 2 +-
.../email/email-send-log.repository.test.ts | 11 +
.../email/email-send-log.repository.ts | 5 +-
.../email/email-send-log.service.test.ts | 7 +-
.../modules/email/email-send-log.service.ts | 4 +-
.../services/kepub-conversion.service.test.ts | 14 +
.../kobo/services/kepub-conversion.service.ts | 8 +-
.../services/kobo-download.service.test.ts | 155 +-
.../kobo/services/kobo-download.service.ts | 89 +-
.../kobo/services/kobo-sync.service.test.ts | 14 +-
.../kobo/services/kobo-sync.service.ts | 11 +-
.../koreader/dto/koreader-plugin.dto.ts | 5 +
.../koreader/koreader-catalog.service.test.ts | 77 +-
.../koreader/koreader-catalog.service.ts | 25 +-
.../koreader/koreader-plugin.service.ts | 42 +-
.../koreader/koreader-stats.service.test.ts | 3 +-
.../koreader/koreader-stats.service.ts | 1 +
.../modules/koreader/koreader.service.test.ts | 2 +
.../src/modules/koreader/koreader.service.ts | 6 +
.../modules/library/dto/create-library.dto.ts | 18 +-
.../modules/library/dto/update-library.dto.ts | 12 +
.../library-folder-roles.utils.test.ts | 56 +
.../library/library-folder-roles.utils.ts | 56 +
.../src/modules/library/library.controller.ts | 8 +
.../src/modules/library/library.dto.test.ts | 17 +
.../library/library.repository.test.ts | 8 +-
.../src/modules/library/library.repository.ts | 161 +-
.../modules/library/library.service.test.ts | 297 +-
server/src/modules/library/library.service.ts | 352 +-
.../metadata-preferences.controller.ts | 4 +
.../metadata/extractors/audio.extractor.ts | 25 +-
.../migration-progress.gateway.test.ts | 8 +-
.../migration/migration-progress.gateway.ts | 6 +-
.../notification/notification.repository.ts | 54 +-
.../notification/notification.service.test.ts | 35 +-
.../notification/notification.service.ts | 34 +-
.../opds/__tests__/opds-book.service.test.ts | 16 +-
server/src/modules/opds/opds-book.service.ts | 13 +-
.../src/modules/path/path-policy.service.ts | 20 +-
.../modules/podcast/dto/podcast.dto.test.ts | 359 +
server/src/modules/podcast/dto/podcast.dto.ts | 784 +
.../podcast/podcast-access.service.test.ts | 77 +
.../modules/podcast/podcast-access.service.ts | 59 +
.../podcast/podcast-acquisition-rules.ts | 8 +
.../podcast/podcast-artwork.service.test.ts | 183 +
.../podcast/podcast-artwork.service.ts | 361 +
.../podcast/podcast-artwork.storage.test.ts | 92 +
.../podcast-catalog.repository.test.ts | 318 +
.../podcast/podcast-catalog.repository.ts | 1157 +
.../podcast/podcast-catalog.service.test.ts | 452 +
.../podcast/podcast-catalog.service.ts | 420 +
.../podcast/podcast-directory.service.test.ts | 121 +
.../podcast/podcast-directory.service.ts | 160 +
.../modules/podcast/podcast-episode-query.ts | 91 +
.../podcast-episode.repository.test.ts | 95 +
.../podcast/podcast-episode.repository.ts | 720 +
.../podcast/podcast-events.service.test.ts | 317 +
.../modules/podcast/podcast-events.service.ts | 249 +
.../podcast/podcast-events.stream.test.ts | 150 +
.../modules/podcast/podcast-feature.test.ts | 48 +
.../podcast/podcast-feed-client.service.ts | 129 +
.../podcast-feed-parser.service.test.ts | 125 +
.../podcast/podcast-feed-parser.service.ts | 373 +
.../podcast-feed-snapshot.service.test.ts | 66 +
.../podcast/podcast-feed-snapshot.service.ts | 68 +
.../podcast/podcast-file-import.service.ts | 765 +
.../modules/podcast/podcast-html-sanitizer.ts | 35 +
.../podcast/podcast-import-files.test.ts | 81 +
.../modules/podcast/podcast-import-files.ts | 121 +
.../podcast/podcast-import-matcher.test.ts | 251 +
.../modules/podcast/podcast-import-matcher.ts | 367 +
.../podcast/podcast-import-services.test.ts | 993 +
.../podcast/podcast-job.repository.test.ts | 59 +
.../modules/podcast/podcast-job.repository.ts | 430 +
.../podcast-local-show-import.service.ts | 626 +
.../podcast-local-watcher.service.test.ts | 245 +
.../podcast/podcast-local-watcher.service.ts | 199 +
.../podcast-media-storage.service.test.ts | 414 +
.../podcast/podcast-media-storage.service.ts | 807 +
.../modules/podcast/podcast-metadata-rules.ts | 40 +
.../podcast-operations.service.test.ts | 920 +
.../podcast/podcast-operations.service.ts | 898 +
.../podcast/podcast-opml.service.test.ts | 44 +
.../modules/podcast/podcast-opml.service.ts | 79 +
.../podcast/podcast-origin-guards.test.ts | 123 +
.../podcast-playback.repository.test.ts | 468 +
.../podcast/podcast-playback.repository.ts | 872 +
.../podcast/podcast-playback.service.test.ts | 712 +
.../podcast/podcast-playback.service.ts | 393 +
.../podcast/podcast-range.utils.test.ts | 30 +
.../modules/podcast/podcast-range.utils.ts | 29 +
.../podcast/podcast-row-mappers.test.ts | 290 +
.../modules/podcast/podcast-row-mappers.ts | 243 +
.../modules/podcast/podcast-secret.service.ts | 57 +
.../podcast/podcast-sidecar.service.test.ts | 119 +
.../podcast/podcast-sidecar.service.ts | 145 +
.../podcast/podcast-stream-auth.guard.test.ts | 90 +
.../podcast/podcast-stream-auth.guard.ts | 41 +
.../podcast-stream-ticket.service.test.ts | 93 +
.../podcast/podcast-stream-ticket.service.ts | 77 +
.../podcast-tag-reader.service.test.ts | 146 +
.../podcast/podcast-tag-reader.service.ts | 349 +
.../podcast-url-security.service.test.ts | 42 +
.../podcast/podcast-url-security.service.ts | 179 +
.../podcast/podcast-worker.service.test.ts | 493 +
.../modules/podcast/podcast-worker.service.ts | 404 +
.../podcast/podcast.controller.test.ts | 177 +
.../src/modules/podcast/podcast.controller.ts | 771 +
.../modules/podcast/podcast.gateway.test.ts | 120 +
server/src/modules/podcast/podcast.gateway.ts | 116 +
server/src/modules/podcast/podcast.module.ts | 115 +
server/src/modules/podcast/podcast.types.ts | 44 +
.../position-converter.service.ts | 110 +-
.../epub/epub-media-overlay-capability.ts | 57 +
.../modules/reader/epub/epub-media-overlay.ts | 399 +
.../reader/epub/epub.controller.test.ts | 35 +
.../modules/reader/epub/epub.controller.ts | 38 +-
server/src/modules/reader/epub/epub.module.ts | 1 +
.../modules/reader/epub/epub.service.test.ts | 104 +
.../src/modules/reader/epub/epub.service.ts | 111 +-
.../dto/save-reading-session.dto.test.ts | 21 +
.../dto/save-reading-session.dto.ts | 15 +-
.../reading-session.controller.test.ts | 20 +-
.../reading-session.controller.ts | 2 +-
.../reading-session.module.test.ts | 3 +-
.../reading-session/reading-session.module.ts | 3 +-
.../reading-session.repository.test.ts | 90 +-
.../reading-session.repository.ts | 56 +-
.../reading-session.service.test.ts | 31 +-
.../reading-session.service.ts | 8 +-
.../reading-state.controller.test.ts | 9 +-
.../reading-state/reading-state.controller.ts | 9 +-
.../scanner/file-event-processor.service.ts | 32 +-
.../modules/scanner/file-watcher.service.ts | 25 +-
server/src/modules/scanner/lib/walk.ts | 169 +-
.../src/modules/scanner/scan.gateway.test.ts | 6 +-
server/src/modules/scanner/scan.gateway.ts | 6 +-
.../src/modules/scanner/scanner.controller.ts | 2 +
.../src/modules/scanner/scanner.repository.ts | 12 +-
.../modules/scanner/scanner.service.test.ts | 38 +-
server/src/modules/scanner/scanner.service.ts | 46 +-
.../search/dto/global-search.dto.test.ts | 23 +
.../modules/search/dto/global-search.dto.ts | 23 +
.../modules/search/search.controller.test.ts | 16 +
.../src/modules/search/search.controller.ts | 16 +
server/src/modules/search/search.module.ts | 14 +
.../src/modules/search/search.service.test.ts | 82 +
server/src/modules/search/search.service.ts | 145 +
.../src/modules/seed/cleanup.service.test.ts | 16 +-
server/src/modules/seed/cleanup.service.ts | 10 +-
.../src/modules/series/dto/list-series.dto.ts | 2 +-
.../src/modules/series/series.repository.ts | 50 +-
.../shared-reading-insights.repository.ts | 4 +-
.../smart-scope/dto/create-smart-scope.dto.ts | 24 +-
.../smart-scope/dto/update-smart-scope.dto.ts | 4 +-
.../smart-scope/smart-scope.controller.ts | 20 +-
.../modules/smart-scope/smart-scope.module.ts | 3 +-
.../smart-scope.repository.test.ts | 17 +-
.../smart-scope/smart-scope.repository.ts | 38 +-
.../smart-scope/smart-scope.service.test.ts | 237 +-
.../smart-scope/smart-scope.service.ts | 200 +-
.../utils/podcast-playlist-migration.test.ts | 102 +
.../utils/podcast-playlist-migration.ts | 100 +
.../podcast-scope-rules.validator.test.ts | 74 +
.../utils/podcast-scope-rules.validator.ts | 101 +
server/src/modules/tts/dto/synthesize.dto.ts | 38 +
server/src/modules/tts/dto/tts-admin.dto.ts | 117 +
.../src/modules/tts/dto/tts-position.dto.ts | 11 +
.../modules/tts/dto/tts-preferences.dto.ts | 17 +
.../openai-compatible.provider.test.ts | 584 +
.../providers/openai-compatible.provider.ts | 429 +
.../tts/providers/tts-provider.factory.ts | 30 +
.../tts/providers/tts-provider.interface.ts | 21 +
.../src/modules/tts/tts-admin.controller.ts | 50 +
.../src/modules/tts/tts-admin.service.test.ts | 322 +
server/src/modules/tts/tts-admin.service.ts | 149 +
server/src/modules/tts/tts-audio-format.ts | 26 +
.../modules/tts/tts-synthesis.service.test.ts | 176 +
.../src/modules/tts/tts-synthesis.service.ts | 169 +
.../tts/tts-text-extractor.service.test.ts | 75 +
.../modules/tts/tts-text-extractor.service.ts | 184 +
server/src/modules/tts/tts.controller.test.ts | 166 +
server/src/modules/tts/tts.controller.ts | 102 +
server/src/modules/tts/tts.module.ts | 18 +
server/src/modules/tts/tts.repository.ts | 126 +
server/src/modules/tts/tts.service.test.ts | 438 +
server/src/modules/tts/tts.service.ts | 277 +
.../upload/dto/create-upload-session.dto.ts | 53 +
server/src/modules/upload/upload-errors.ts | 33 +
.../upload/upload-processor.service.test.ts | 18 +-
.../upload/upload-processor.service.ts | 72 +-
.../upload/upload-session.controller.test.ts | 55 +
.../upload/upload-session.controller.ts | 61 +
.../upload/upload-session.repository.ts | 137 +
.../upload/upload-session.service.test.ts | 271 +
.../modules/upload/upload-session.service.ts | 464 +
.../modules/upload/upload-storage.service.ts | 46 +-
.../upload/upload-validator.service.test.ts | 61 +-
.../upload/upload-validator.service.ts | 87 +-
.../src/modules/upload/upload.controller.ts | 2 +
.../src/modules/upload/upload.module.test.ts | 8 +-
server/src/modules/upload/upload.module.ts | 10 +-
.../src/modules/upload/upload.service.test.ts | 5 +
server/src/modules/upload/upload.service.ts | 118 +-
.../user-preferences.controller.ts | 24 +
.../user-preferences.service.test.ts | 55 +
.../user-preferences.service.ts | 78 +
.../dto/activity-calendar-query.dto.ts | 3 +
.../activity-completion-speed-query.dto.ts | 10 +
.../dto/activity-day-query.dto.ts | 3 +
.../dto/activity-pace-query.dto.ts | 15 +
.../user-statistics.controller.test.ts | 21 +
.../user-statistics.controller.ts | 39 +
.../user-statistics.repository.ts | 356 +-
.../user-statistics.service.test.ts | 343 +-
.../user-statistics.service.ts | 591 +-
.../dto/create-watch-download.dto.ts | 14 +
...create-watch-media-overlay-download.dto.ts | 18 +
.../watch-download.controller.ts | 127 +
.../watch-download/watch-download.module.ts | 29 +
.../watch-download.service.test.ts | 195 +
.../watch-download/watch-download.service.ts | 230 +
server/src/scripts/README.md | 1 +
.../migrate-podcast-playlists-to-scopes.ts | 113 +
.../src/scripts/register-kokoro-provider.ts | 65 +
server/src/scripts/seed-apple-source-stats.ts | 310 +
.../auth-recovery-oidc-logout.e2e-spec.ts | 168 +-
server/test/authorization-matrix.e2e-spec.ts | 200 +-
server/test/e2e.setup.ts | 3 +-
server/test/e2e/app-harness.ts | 4 +-
.../authorization-matrix-harness.ts | 2 +
.../route-inventory/manifest.json | 26 +-
.../route-inventory/routes-02.json | 24 +
.../route-inventory/routes-09.json | 782 +
.../route-inventory/routes-10.json | 770 +
844 files changed, 151839 insertions(+), 3415 deletions(-)
create mode 100644 client/public/assets/foliate/overlayer.d.ts
create mode 100644 client/public/assets/foliate/text-walker.d.ts
create mode 100644 client/public/assets/foliate/tts.d.ts
create mode 100644 client/src/components/ChipGroup.vue
create mode 100644 client/src/components/EmptyState.vue
create mode 100644 client/src/components/FilterChip.vue
create mode 100644 client/src/components/FormSheet.vue
create mode 100644 client/src/components/ListEndFooter.vue
create mode 100644 client/src/components/ListSkeleton.vue
create mode 100644 client/src/components/LoadErrorPanel.vue
create mode 100644 client/src/components/__tests__/EmptyState.spec.ts
create mode 100644 client/src/components/__tests__/FormSheet.spec.ts
create mode 100644 client/src/components/entity-index/__tests__/EntityIndexView.spec.ts
create mode 100644 client/src/components/media/MediaScrubber.vue
create mode 100644 client/src/components/media/MediaTransport.vue
create mode 100644 client/src/components/media/MediaVolumeControl.vue
create mode 100644 client/src/components/media/__tests__/MediaScrubber.spec.ts
create mode 100644 client/src/components/media/__tests__/MediaTransport.spec.ts
create mode 100644 client/src/components/media/__tests__/MediaVolumeControl.spec.ts
create mode 100644 client/src/components/media/types.ts
create mode 100644 client/src/components/sidebar/SidebarModeSwitch.vue
create mode 100644 client/src/components/sidebar/__tests__/sidebar-order.spec.ts
create mode 100644 client/src/components/sidebar/sidebar-order.ts
create mode 100644 client/src/components/ui/Chip.vue
create mode 100644 client/src/components/ui/__tests__/ConfirmDialog.spec.ts
create mode 100644 client/src/components/ui/select/Select.vue
create mode 100644 client/src/components/ui/select/index.ts
create mode 100644 client/src/components/ui/textarea/Textarea.vue
create mode 100644 client/src/components/ui/textarea/index.ts
create mode 100644 client/src/composables/__tests__/useMediaMode.spec.ts
create mode 100644 client/src/composables/useFilePicker.ts
rename client/src/{features/book => }/composables/useInfiniteScrollSentinel.ts (100%)
create mode 100644 client/src/composables/useMediaMode.ts
create mode 100644 client/src/composables/usePagedEntityMembers.ts
create mode 100644 client/src/composables/useScrollOverflow.ts
create mode 100644 client/src/features/book/lib/file-capabilities.ts
create mode 100644 client/src/features/collection/components/CollectionMembershipList.vue
create mode 100644 client/src/features/collection/components/__tests__/CreateCollectionDialog.spec.ts
create mode 100644 client/src/features/dashboard/components/DashboardPodcastScroller.spec.ts
create mode 100644 client/src/features/dashboard/components/DashboardPodcastScroller.vue
create mode 100644 client/src/features/dashboard/composables/useDashboardPodcastScroller.ts
create mode 100644 client/src/features/library/components/LibraryCreatorLocalFolders.vue
create mode 100644 client/src/features/podcast/components/AddShowToCollectionSheet.spec.ts
create mode 100644 client/src/features/podcast/components/AddShowToCollectionSheet.vue
create mode 100644 client/src/features/podcast/components/PodcastAcquisitionField.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastAcquisitionField.vue
create mode 100644 client/src/features/podcast/components/PodcastAddFeedSheet.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastAddFeedSheet.vue
create mode 100644 client/src/features/podcast/components/PodcastArtwork.vue
create mode 100644 client/src/features/podcast/components/PodcastBookmarksPanel.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastBookmarksPanel.vue
create mode 100644 client/src/features/podcast/components/PodcastDownloadWidget.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastDownloadWidget.vue
create mode 100644 client/src/features/podcast/components/PodcastEditDetailsSheet.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastEditDetailsSheet.vue
create mode 100644 client/src/features/podcast/components/PodcastEpisodeEditSheet.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastEpisodeEditSheet.vue
create mode 100644 client/src/features/podcast/components/PodcastEpisodeQuickView.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastEpisodeQuickView.vue
create mode 100644 client/src/features/podcast/components/PodcastEpisodeResultsPanel.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastEpisodeResultsPanel.vue
create mode 100644 client/src/features/podcast/components/PodcastEpisodeRow.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastEpisodeRow.vue
create mode 100644 client/src/features/podcast/components/PodcastEpisodeStatusChips.vue
create mode 100644 client/src/features/podcast/components/PodcastFieldLabel.vue
create mode 100644 client/src/features/podcast/components/PodcastHealthPanel.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastHealthPanel.vue
create mode 100644 client/src/features/podcast/components/PodcastImportGroup.vue
create mode 100644 client/src/features/podcast/components/PodcastImportSheet.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastImportSheet.vue
create mode 100644 client/src/features/podcast/components/PodcastLiveRegion.vue
create mode 100644 client/src/features/podcast/components/PodcastMiniPlayer.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastMiniPlayer.vue
create mode 100644 client/src/features/podcast/components/PodcastOpmlImportSheet.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastOpmlImportSheet.vue
create mode 100644 client/src/features/podcast/components/PodcastPlaylistEditor.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastPlaylistEditor.vue
create mode 100644 client/src/features/podcast/components/PodcastPlaylistRuleSummary.vue
create mode 100644 client/src/features/podcast/components/PodcastQueueList.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastQueueList.vue
create mode 100644 client/src/features/podcast/components/PodcastQueuePanel.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastQueuePanel.vue
create mode 100644 client/src/features/podcast/components/PodcastShortcutsDialog.vue
create mode 100644 client/src/features/podcast/components/PodcastShowCard.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastShowCard.vue
create mode 100644 client/src/features/podcast/components/PodcastShowManageSheet.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastShowManageSheet.vue
create mode 100644 client/src/features/podcast/components/PodcastShowSettingsSheet.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastShowSettingsSheet.vue
create mode 100644 client/src/features/podcast/components/PodcastShowsPanel.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastShowsPanel.vue
create mode 100644 client/src/features/podcast/components/PodcastStatusChip.spec.ts
create mode 100644 client/src/features/podcast/components/PodcastStatusChip.vue
create mode 100644 client/src/features/podcast/components/PodcastViewHeader.vue
create mode 100644 client/src/features/podcast/composables/createLockedMetadataForm.ts
create mode 100644 client/src/features/podcast/composables/podcast-audio-engine.spec.ts
create mode 100644 client/src/features/podcast/composables/podcast-audio-engine.ts
create mode 100644 client/src/features/podcast/composables/podcast-list-lane.ts
create mode 100644 client/src/features/podcast/composables/podcast-media-session.spec.ts
create mode 100644 client/src/features/podcast/composables/podcast-media-session.ts
create mode 100644 client/src/features/podcast/composables/podcast-player-actions.ts
create mode 100644 client/src/features/podcast/composables/podcast-player-persistence.spec.ts
create mode 100644 client/src/features/podcast/composables/podcast-player-persistence.ts
create mode 100644 client/src/features/podcast/composables/podcast-player-preferences.ts
create mode 100644 client/src/features/podcast/composables/podcast-player-queue.ts
create mode 100644 client/src/features/podcast/composables/podcast-player-state.spec.ts
create mode 100644 client/src/features/podcast/composables/podcast-player-state.ts
create mode 100644 client/src/features/podcast/composables/podcast-sleep-timer.spec.ts
create mode 100644 client/src/features/podcast/composables/podcast-sleep-timer.ts
create mode 100644 client/src/features/podcast/composables/useEpisodeQuickView.ts
create mode 100644 client/src/features/podcast/composables/usePodcastAnnouncer.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastAnnouncer.ts
create mode 100644 client/src/features/podcast/composables/usePodcastArtworkEditor.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastArtworkEditor.ts
create mode 100644 client/src/features/podcast/composables/usePodcastBulkDelete.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastBulkDelete.ts
create mode 100644 client/src/features/podcast/composables/usePodcastDownloadBatches.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastDownloadBatches.ts
create mode 100644 client/src/features/podcast/composables/usePodcastEpisodeDetail.ts
create mode 100644 client/src/features/podcast/composables/usePodcastEpisodeListActions.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastEpisodeListActions.ts
create mode 100644 client/src/features/podcast/composables/usePodcastEpisodeMetadataEditor.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastEpisodeMetadataEditor.ts
create mode 100644 client/src/features/podcast/composables/usePodcastEvents.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastEvents.ts
create mode 100644 client/src/features/podcast/composables/usePodcastFailingFeedCount.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastFailingFeedCount.ts
create mode 100644 client/src/features/podcast/composables/usePodcastFeedAdd.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastFeedAdd.ts
create mode 100644 client/src/features/podcast/composables/usePodcastFeedHealth.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastFeedHealth.ts
create mode 100644 client/src/features/podcast/composables/usePodcastImport.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastImport.ts
create mode 100644 client/src/features/podcast/composables/usePodcastImportProgress.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastImportProgress.ts
create mode 100644 client/src/features/podcast/composables/usePodcastKeyboardShortcuts.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastKeyboardShortcuts.ts
create mode 100644 client/src/features/podcast/composables/usePodcastLibraryAccess.ts
create mode 100644 client/src/features/podcast/composables/usePodcastLibraryPage.ts
create mode 100644 client/src/features/podcast/composables/usePodcastMetadataEditor.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastMetadataEditor.ts
create mode 100644 client/src/features/podcast/composables/usePodcastOpmlTransfer.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastOpmlTransfer.ts
create mode 100644 client/src/features/podcast/composables/usePodcastPlayer.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastPlayer.ts
create mode 100644 client/src/features/podcast/composables/usePodcastPlayerDisplay.ts
create mode 100644 client/src/features/podcast/composables/usePodcastPlaylistPreview.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastPlaylistPreview.ts
create mode 100644 client/src/features/podcast/composables/usePodcastPlaylists.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastPlaylists.ts
create mode 100644 client/src/features/podcast/composables/usePodcastQueue.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastQueue.ts
create mode 100644 client/src/features/podcast/composables/usePodcastQueueMaintenance.ts
create mode 100644 client/src/features/podcast/composables/usePodcastSettings.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastSettings.ts
create mode 100644 client/src/features/podcast/composables/usePodcastShortcuts.ts
create mode 100644 client/src/features/podcast/composables/usePodcastShowManagement.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastShowManagement.ts
create mode 100644 client/src/features/podcast/composables/usePodcastShowPage.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastShowPage.ts
create mode 100644 client/src/features/podcast/composables/usePodcastShowSelection.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcastShowSelection.ts
create mode 100644 client/src/features/podcast/composables/usePodcasts.spec.ts
create mode 100644 client/src/features/podcast/composables/usePodcasts.ts
create mode 100644 client/src/features/podcast/lib/podcast-bulk.spec.ts
create mode 100644 client/src/features/podcast/lib/podcast-bulk.ts
create mode 100644 client/src/features/podcast/lib/podcast-episode-api.spec.ts
create mode 100644 client/src/features/podcast/lib/podcast-episode-api.ts
create mode 100644 client/src/features/podcast/lib/podcast-episode-filters.ts
create mode 100644 client/src/features/podcast/lib/podcast-episode-presentation.ts
create mode 100644 client/src/features/podcast/lib/podcast-format.spec.ts
create mode 100644 client/src/features/podcast/lib/podcast-format.ts
create mode 100644 client/src/features/podcast/lib/podcast-locks.ts
create mode 100644 client/src/features/podcast/lib/podcast-playback-rates.ts
create mode 100644 client/src/features/podcast/lib/podcast-player-visibility.spec.ts
create mode 100644 client/src/features/podcast/lib/podcast-player-visibility.ts
create mode 100644 client/src/features/podcast/lib/podcast-playlist-rules.spec.ts
create mode 100644 client/src/features/podcast/lib/podcast-playlist-rules.ts
create mode 100644 client/src/features/podcast/test/audio-engine-stub.ts
create mode 100644 client/src/features/podcast/test/episode-action-stubs.ts
create mode 100644 client/src/features/podcast/test/fixtures.ts
create mode 100644 client/src/features/podcast/test/stubs.ts
create mode 100644 client/src/features/podcast/views/PodcastCollectionView.spec.ts
create mode 100644 client/src/features/podcast/views/PodcastCollectionView.vue
create mode 100644 client/src/features/podcast/views/PodcastLibrariesView.vue
create mode 100644 client/src/features/podcast/views/PodcastLibraryView.spec.ts
create mode 100644 client/src/features/podcast/views/PodcastLibraryView.vue
create mode 100644 client/src/features/podcast/views/PodcastPlayerView.spec.ts
create mode 100644 client/src/features/podcast/views/PodcastPlayerView.vue
create mode 100644 client/src/features/podcast/views/PodcastScopeView.spec.ts
create mode 100644 client/src/features/podcast/views/PodcastScopeView.vue
create mode 100644 client/src/features/podcast/views/PodcastShowView.spec.ts
create mode 100644 client/src/features/podcast/views/PodcastShowView.vue
create mode 100644 client/src/features/reader/media-overlay/components/MediaOverlayMiniPlayer.vue
create mode 100644 client/src/features/reader/media-overlay/composables/useMediaOverlay.test.ts
create mode 100644 client/src/features/reader/media-overlay/composables/useMediaOverlay.ts
create mode 100644 client/src/features/reader/media-overlay/lib/media-overlay-resume.test.ts
create mode 100644 client/src/features/reader/media-overlay/lib/media-overlay-resume.ts
create mode 100644 client/src/features/reader/shared/composables/__tests__/useVisibility.spec.ts
create mode 100644 client/src/features/settings/PodcastSettings.vue
create mode 100644 client/src/features/tts/TtsAdminSettings.test.ts
create mode 100644 client/src/features/tts/TtsAdminSettings.vue
create mode 100644 client/src/features/tts/TtsOpenAiVoiceCuration.test.ts
create mode 100644 client/src/features/tts/TtsOpenAiVoiceCuration.vue
create mode 100644 client/src/features/tts/TtsSettings.vue
create mode 100644 client/src/features/tts/api/tts.api.ts
create mode 100644 client/src/features/tts/components/TtsAdminProviderCard.vue
create mode 100644 client/src/features/tts/components/TtsAdminProviderForm.vue
create mode 100644 client/src/features/tts/components/TtsAdminVoiceChips.vue
create mode 100644 client/src/features/tts/components/TtsMiniPlayer.vue
create mode 100644 client/src/features/tts/components/TtsMiniPlayerExpanded.vue
create mode 100644 client/src/features/tts/components/TtsReaderControls.vue
create mode 100644 client/src/features/tts/components/TtsResumePrompt.vue
create mode 100644 client/src/features/tts/components/TtsSleepTimerPicker.vue
create mode 100644 client/src/features/tts/components/TtsSpeedControl.vue
create mode 100644 client/src/features/tts/components/TtsVoicePicker.vue
create mode 100644 client/src/features/tts/composables/useFoliateTts.test.ts
create mode 100644 client/src/features/tts/composables/useFoliateTts.ts
create mode 100644 client/src/features/tts/composables/useTtsAdminProviders.ts
create mode 100644 client/src/features/tts/composables/useTtsKeyboard.ts
create mode 100644 client/src/features/tts/composables/useTtsMediaSession.ts
create mode 100644 client/src/features/tts/composables/useTtsMiniPlayerUi.ts
create mode 100644 client/src/features/tts/composables/useTtsPlayer.test.ts
create mode 100644 client/src/features/tts/composables/useTtsPlayer.ts
create mode 100644 client/src/features/tts/composables/useTtsPosition.ts
create mode 100644 client/src/features/tts/composables/useTtsPreferences.test.ts
create mode 100644 client/src/features/tts/composables/useTtsPreferences.ts
create mode 100644 client/src/features/tts/composables/useTtsReadingSession.ts
create mode 100644 client/src/features/tts/composables/useTtsSleepTimer.test.ts
create mode 100644 client/src/features/tts/composables/useTtsSleepTimer.ts
create mode 100644 client/src/features/tts/composables/useTtsVoices.test.ts
create mode 100644 client/src/features/tts/composables/useTtsVoices.ts
create mode 100644 client/src/features/tts/lib/tts-state.ts
create mode 100644 client/src/features/tts/lib/voice-display.test.ts
create mode 100644 client/src/features/tts/lib/voice-display.ts
create mode 100644 client/src/features/tts/lib/voice-presets.test.ts
create mode 100644 client/src/features/tts/lib/voice-presets.ts
create mode 100644 client/src/lib/__tests__/api-json.spec.ts
create mode 100644 client/src/lib/__tests__/audio-focus.spec.ts
create mode 100644 client/src/lib/__tests__/entity-count.spec.ts
create mode 100644 client/src/lib/api-json.ts
create mode 100644 client/src/lib/async.ts
create mode 100644 client/src/lib/audio-focus.ts
create mode 100644 client/src/lib/entity-count.ts
create mode 100644 client/src/router/__tests__/podcast-feature.spec.ts
delete mode 100644 docs/BOOK_REQUESTS.md
delete mode 100644 docs/whats-new-authoring-guide.md
delete mode 100644 docs/whats-new-template.md
create mode 100644 packages/types/src/activity.ts
create mode 100644 packages/types/src/app-features.ts
create mode 100644 packages/types/src/media.ts
create mode 100644 packages/types/src/podcast.ts
create mode 100644 packages/types/src/search.ts
create mode 100644 packages/types/src/tts.ts
create mode 100644 packages/types/src/watch-download.ts
create mode 100644 server/src/common/constants/upload.constants.ts
create mode 100644 server/src/common/decorators/require-library-type.decorator.ts
create mode 100644 server/src/common/fs-walk.utils.test.ts
create mode 100644 server/src/common/fs-walk.utils.ts
create mode 100644 server/src/common/pipes/optional-int-query.pipe.test.ts
create mode 100644 server/src/common/pipes/optional-int-query.pipe.ts
create mode 100644 server/src/common/podcast-artwork-storage.ts
create mode 100644 server/src/common/podcast-feed-snapshot-storage.ts
rename server/src/{modules/scanner/lib/stability.test.ts => common/utils/fs-stability.utils.test.ts} (98%)
rename server/src/{modules/scanner/lib/stability.ts => common/utils/fs-stability.utils.ts} (96%)
create mode 100644 server/src/common/utils/fs-watch.utils.ts
create mode 100644 server/src/common/utils/log-url.utils.test.ts
create mode 100644 server/src/common/utils/log-url.utils.ts
create mode 100644 server/src/db/migrations/0091_add_read_aloud_and_podcasts.sql
create mode 100644 server/src/db/migrations/0092_narration_progress_columns.sql
create mode 100644 server/src/db/migrations/0093_add_device_auth_sessions.sql
create mode 100644 server/src/db/migrations/meta/0091_snapshot.json
create mode 100644 server/src/db/migrations/meta/0092_snapshot.json
create mode 100644 server/src/db/migrations/meta/0093_snapshot.json
create mode 100644 server/src/db/schema/podcasts.ts
create mode 100644 server/src/db/schema/tts.ts
create mode 100644 server/src/db/schema/upload-sessions.ts
create mode 100644 server/src/modules/auth/auth-session-transport.test.ts
create mode 100644 server/src/modules/auth/auth-session.repository.test.ts
create mode 100644 server/src/modules/auth/auth-session.repository.ts
create mode 100644 server/src/modules/auth/auth-session.service.test.ts
create mode 100644 server/src/modules/auth/auth-session.service.ts
create mode 100644 server/src/modules/auth/dto/auth-client.dto.ts
create mode 100644 server/src/modules/auth/dto/logout.dto.ts
create mode 100644 server/src/modules/auth/dto/refresh.dto.ts
create mode 100644 server/src/modules/auth/oidc/redirect-uri.test.ts
create mode 100644 server/src/modules/auth/oidc/redirect-uri.ts
create mode 100644 server/src/modules/auth/refresh-token.utils.ts
create mode 100644 server/src/modules/book/audiobook-ebook-progress-sync.service.ts
create mode 100644 server/src/modules/book/audioless-epub.service.test.ts
create mode 100644 server/src/modules/book/audioless-epub.service.ts
create mode 100644 server/src/modules/book/book-access-hides-existence.test.ts
create mode 100644 server/src/modules/book/dto/update-read-aloud-sync-settings.dto.ts
create mode 100644 server/src/modules/book/dto/upsert-audio-progress.dto.ts
create mode 100644 server/src/modules/collection/dto/collection-podcasts.dto.ts
create mode 100644 server/src/modules/library/library-folder-roles.utils.test.ts
create mode 100644 server/src/modules/library/library-folder-roles.utils.ts
create mode 100644 server/src/modules/podcast/dto/podcast.dto.test.ts
create mode 100644 server/src/modules/podcast/dto/podcast.dto.ts
create mode 100644 server/src/modules/podcast/podcast-access.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-access.service.ts
create mode 100644 server/src/modules/podcast/podcast-acquisition-rules.ts
create mode 100644 server/src/modules/podcast/podcast-artwork.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-artwork.service.ts
create mode 100644 server/src/modules/podcast/podcast-artwork.storage.test.ts
create mode 100644 server/src/modules/podcast/podcast-catalog.repository.test.ts
create mode 100644 server/src/modules/podcast/podcast-catalog.repository.ts
create mode 100644 server/src/modules/podcast/podcast-catalog.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-catalog.service.ts
create mode 100644 server/src/modules/podcast/podcast-directory.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-directory.service.ts
create mode 100644 server/src/modules/podcast/podcast-episode-query.ts
create mode 100644 server/src/modules/podcast/podcast-episode.repository.test.ts
create mode 100644 server/src/modules/podcast/podcast-episode.repository.ts
create mode 100644 server/src/modules/podcast/podcast-events.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-events.service.ts
create mode 100644 server/src/modules/podcast/podcast-events.stream.test.ts
create mode 100644 server/src/modules/podcast/podcast-feature.test.ts
create mode 100644 server/src/modules/podcast/podcast-feed-client.service.ts
create mode 100644 server/src/modules/podcast/podcast-feed-parser.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-feed-parser.service.ts
create mode 100644 server/src/modules/podcast/podcast-feed-snapshot.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-feed-snapshot.service.ts
create mode 100644 server/src/modules/podcast/podcast-file-import.service.ts
create mode 100644 server/src/modules/podcast/podcast-html-sanitizer.ts
create mode 100644 server/src/modules/podcast/podcast-import-files.test.ts
create mode 100644 server/src/modules/podcast/podcast-import-files.ts
create mode 100644 server/src/modules/podcast/podcast-import-matcher.test.ts
create mode 100644 server/src/modules/podcast/podcast-import-matcher.ts
create mode 100644 server/src/modules/podcast/podcast-import-services.test.ts
create mode 100644 server/src/modules/podcast/podcast-job.repository.test.ts
create mode 100644 server/src/modules/podcast/podcast-job.repository.ts
create mode 100644 server/src/modules/podcast/podcast-local-show-import.service.ts
create mode 100644 server/src/modules/podcast/podcast-local-watcher.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-local-watcher.service.ts
create mode 100644 server/src/modules/podcast/podcast-media-storage.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-media-storage.service.ts
create mode 100644 server/src/modules/podcast/podcast-metadata-rules.ts
create mode 100644 server/src/modules/podcast/podcast-operations.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-operations.service.ts
create mode 100644 server/src/modules/podcast/podcast-opml.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-opml.service.ts
create mode 100644 server/src/modules/podcast/podcast-origin-guards.test.ts
create mode 100644 server/src/modules/podcast/podcast-playback.repository.test.ts
create mode 100644 server/src/modules/podcast/podcast-playback.repository.ts
create mode 100644 server/src/modules/podcast/podcast-playback.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-playback.service.ts
create mode 100644 server/src/modules/podcast/podcast-range.utils.test.ts
create mode 100644 server/src/modules/podcast/podcast-range.utils.ts
create mode 100644 server/src/modules/podcast/podcast-row-mappers.test.ts
create mode 100644 server/src/modules/podcast/podcast-row-mappers.ts
create mode 100644 server/src/modules/podcast/podcast-secret.service.ts
create mode 100644 server/src/modules/podcast/podcast-sidecar.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-sidecar.service.ts
create mode 100644 server/src/modules/podcast/podcast-stream-auth.guard.test.ts
create mode 100644 server/src/modules/podcast/podcast-stream-auth.guard.ts
create mode 100644 server/src/modules/podcast/podcast-stream-ticket.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-stream-ticket.service.ts
create mode 100644 server/src/modules/podcast/podcast-tag-reader.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-tag-reader.service.ts
create mode 100644 server/src/modules/podcast/podcast-url-security.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-url-security.service.ts
create mode 100644 server/src/modules/podcast/podcast-worker.service.test.ts
create mode 100644 server/src/modules/podcast/podcast-worker.service.ts
create mode 100644 server/src/modules/podcast/podcast.controller.test.ts
create mode 100644 server/src/modules/podcast/podcast.controller.ts
create mode 100644 server/src/modules/podcast/podcast.gateway.test.ts
create mode 100644 server/src/modules/podcast/podcast.gateway.ts
create mode 100644 server/src/modules/podcast/podcast.module.ts
create mode 100644 server/src/modules/podcast/podcast.types.ts
create mode 100644 server/src/modules/reader/epub/epub-media-overlay-capability.ts
create mode 100644 server/src/modules/reader/epub/epub-media-overlay.ts
create mode 100644 server/src/modules/search/dto/global-search.dto.test.ts
create mode 100644 server/src/modules/search/dto/global-search.dto.ts
create mode 100644 server/src/modules/search/search.controller.test.ts
create mode 100644 server/src/modules/search/search.controller.ts
create mode 100644 server/src/modules/search/search.module.ts
create mode 100644 server/src/modules/search/search.service.test.ts
create mode 100644 server/src/modules/search/search.service.ts
create mode 100644 server/src/modules/smart-scope/utils/podcast-playlist-migration.test.ts
create mode 100644 server/src/modules/smart-scope/utils/podcast-playlist-migration.ts
create mode 100644 server/src/modules/smart-scope/utils/podcast-scope-rules.validator.test.ts
create mode 100644 server/src/modules/smart-scope/utils/podcast-scope-rules.validator.ts
create mode 100644 server/src/modules/tts/dto/synthesize.dto.ts
create mode 100644 server/src/modules/tts/dto/tts-admin.dto.ts
create mode 100644 server/src/modules/tts/dto/tts-position.dto.ts
create mode 100644 server/src/modules/tts/dto/tts-preferences.dto.ts
create mode 100644 server/src/modules/tts/providers/openai-compatible.provider.test.ts
create mode 100644 server/src/modules/tts/providers/openai-compatible.provider.ts
create mode 100644 server/src/modules/tts/providers/tts-provider.factory.ts
create mode 100644 server/src/modules/tts/providers/tts-provider.interface.ts
create mode 100644 server/src/modules/tts/tts-admin.controller.ts
create mode 100644 server/src/modules/tts/tts-admin.service.test.ts
create mode 100644 server/src/modules/tts/tts-admin.service.ts
create mode 100644 server/src/modules/tts/tts-audio-format.ts
create mode 100644 server/src/modules/tts/tts-synthesis.service.test.ts
create mode 100644 server/src/modules/tts/tts-synthesis.service.ts
create mode 100644 server/src/modules/tts/tts-text-extractor.service.test.ts
create mode 100644 server/src/modules/tts/tts-text-extractor.service.ts
create mode 100644 server/src/modules/tts/tts.controller.test.ts
create mode 100644 server/src/modules/tts/tts.controller.ts
create mode 100644 server/src/modules/tts/tts.module.ts
create mode 100644 server/src/modules/tts/tts.repository.ts
create mode 100644 server/src/modules/tts/tts.service.test.ts
create mode 100644 server/src/modules/tts/tts.service.ts
create mode 100644 server/src/modules/upload/dto/create-upload-session.dto.ts
create mode 100644 server/src/modules/upload/upload-errors.ts
create mode 100644 server/src/modules/upload/upload-session.controller.test.ts
create mode 100644 server/src/modules/upload/upload-session.controller.ts
create mode 100644 server/src/modules/upload/upload-session.repository.ts
create mode 100644 server/src/modules/upload/upload-session.service.test.ts
create mode 100644 server/src/modules/upload/upload-session.service.ts
create mode 100644 server/src/modules/user-statistics/dto/activity-calendar-query.dto.ts
create mode 100644 server/src/modules/user-statistics/dto/activity-completion-speed-query.dto.ts
create mode 100644 server/src/modules/user-statistics/dto/activity-day-query.dto.ts
create mode 100644 server/src/modules/user-statistics/dto/activity-pace-query.dto.ts
create mode 100644 server/src/modules/watch-download/dto/create-watch-download.dto.ts
create mode 100644 server/src/modules/watch-download/dto/create-watch-media-overlay-download.dto.ts
create mode 100644 server/src/modules/watch-download/watch-download.controller.ts
create mode 100644 server/src/modules/watch-download/watch-download.module.ts
create mode 100644 server/src/modules/watch-download/watch-download.service.test.ts
create mode 100644 server/src/modules/watch-download/watch-download.service.ts
create mode 100644 server/src/scripts/migrate-podcast-playlists-to-scopes.ts
create mode 100644 server/src/scripts/register-kokoro-provider.ts
create mode 100644 server/src/scripts/seed-apple-source-stats.ts
create mode 100644 server/test/e2e/authorization-matrix/route-inventory/routes-09.json
create mode 100644 server/test/e2e/authorization-matrix/route-inventory/routes-10.json
diff --git a/.env.example b/.env.example
index 1e442e20c..ab501198c 100644
--- a/.env.example
+++ b/.env.example
@@ -25,6 +25,9 @@ POSTGRES_DB=bookorbit
# App secrets
JWT_SECRET=change-this-to-a-long-random-secret
+# Fresh installs should generate an independent key with: openssl rand -hex 32
+# Existing installs upgrading from an older release must initially copy their current JWT_SECRET here so existing podcast URLs remain readable.
+PODCAST_ENCRYPTION_KEY=change-this-to-a-different-long-random-secret
# Required for the initial /auth/setup endpoint (x-setup-token header)
SETUP_BOOTSTRAP_TOKEN=change-this-to-a-long-random-secret
diff --git a/README.md b/README.md
index 4f25f2067..b9b80f35f 100644
--- a/README.md
+++ b/README.md
@@ -82,6 +82,7 @@ BOOKS_HOST_PATH=./books # folder on your server where your book fil
POSTGRES_PASSWORD= # database password - openssl rand -hex 24
JWT_SECRET= # signs login tokens - openssl rand -hex 32
+PODCAST_ENCRYPTION_KEY= # encrypts podcast URLs - openssl rand -hex 32
SETUP_BOOTSTRAP_TOKEN= # one-time setup wizard token - openssl rand -hex 16
```
@@ -107,6 +108,8 @@ Set `POSTGRES_PASSWORD=` and `POSTGRES_PASSWORD_FILE=/run/secrets/bookorbit_post
On a NAS, or any host where your book folder is owned by a user other than UID 1000, also set `PUID` and `PGID` to match that owner. Run `id -u` and `id -g` as the owning user to find them. Getting these wrong is the most common cause of permission errors on first scan.
+When upgrading an installation that previously used podcasts without `PODCAST_ENCRYPTION_KEY`, set it to the current `JWT_SECRET` first. A new key would make existing encrypted podcast URLs unreadable. Fresh installations should use an independent generated key.
+
Optionally set `LIBRARY_BROWSE_ROOT=/books` to start the library folder picker at `/books` instead of `/`.
Then start:
diff --git a/client/eslint.config.ts b/client/eslint.config.ts
index c1c7f9916..9a11ddffd 100644
--- a/client/eslint.config.ts
+++ b/client/eslint.config.ts
@@ -23,7 +23,16 @@ export default defineConfigWithVueTs(
{
...pluginVitest.configs.recommended,
- files: ['src/**/__tests__/*'],
+ // Specs are colocated as `*.spec.ts`; `__tests__/` remains only in features not yet converted.
+ files: ['src/**/__tests__/*', 'src/**/*.spec.ts', 'src/**/*.test.ts'],
+ },
+
+ // Named handlers only: an inline arrow in a template cannot be named, reused or tested, and the
+ // podcast feature was swept clean of them. The rest of the app still carries several hundred, so
+ // widening this is its own task rather than a side effect of one feature's refactor.
+ {
+ files: ['src/features/podcast/**/*.vue', 'src/components/FormSheet.vue', 'src/features/collection/components/CollectionMembershipList.vue'],
+ rules: { 'vue/v-on-handler-style': ['error', ['method', 'inline']] },
},
{
diff --git a/client/public/assets/foliate/overlayer.d.ts b/client/public/assets/foliate/overlayer.d.ts
new file mode 100644
index 000000000..26e0c2314
--- /dev/null
+++ b/client/public/assets/foliate/overlayer.d.ts
@@ -0,0 +1,10 @@
+export declare class Overlayer {
+ add(key: string, range: Range, draw: (rects: DOMRectList, opts: unknown) => Element, options?: unknown): void
+ remove(key: string): void
+ redraw(): void
+ static highlight(rects: DOMRectList, options?: { color?: string }): Element
+ static underline(rects: DOMRectList, options?: { color?: string; width?: number }): Element
+ static strikethrough(rects: DOMRectList, options?: { color?: string; width?: number }): Element
+ static squiggly(rects: DOMRectList, options?: { color?: string; width?: number }): Element
+ static outline(rects: DOMRectList, options?: { color?: string; width?: number; radius?: number }): Element
+}
diff --git a/client/public/assets/foliate/text-walker.d.ts b/client/public/assets/foliate/text-walker.d.ts
new file mode 100644
index 000000000..7f5a343d0
--- /dev/null
+++ b/client/public/assets/foliate/text-walker.d.ts
@@ -0,0 +1 @@
+export declare function textWalker(doc: Document): Generator<{ node: Text; start: number; end: number }>
diff --git a/client/public/assets/foliate/tts.d.ts b/client/public/assets/foliate/tts.d.ts
new file mode 100644
index 000000000..4647d5c2f
--- /dev/null
+++ b/client/public/assets/foliate/tts.d.ts
@@ -0,0 +1,9 @@
+export declare class TTS {
+ constructor(doc: Document, textWalker: unknown, highlightCallback: (range: Range) => void, granularity: string)
+ start(): string | null
+ resume(): string | null
+ prev(paused?: boolean): string | null
+ next(paused?: boolean): string | null
+ from(range: Range): string | null
+ setMark(mark: string): void
+}
diff --git a/client/src/App.vue b/client/src/App.vue
index 7d3efc791..708dd650f 100644
--- a/client/src/App.vue
+++ b/client/src/App.vue
@@ -2,6 +2,7 @@
import { computed, provide, watch } from 'vue'
import { useRoute } from 'vue-router'
import { INIT_OPTIONS_KEY, THEME_KEY } from 'vue-echarts'
+import { APP_FEATURES } from '@bookorbit/types'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import { useThemeStore } from '@/stores/theme'
import { getBookorbitThemeName, initChartThemes } from '@/lib/echarts'
@@ -9,22 +10,64 @@ import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue'
import WhatsNewDialog from '@/features/whats-new/WhatsNewDialog.vue'
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
import { useAuth } from '@/features/auth/composables/useAuth'
+import TtsMiniPlayer from '@/features/tts/components/TtsMiniPlayer.vue'
+import MediaOverlayMiniPlayer from '@/features/reader/media-overlay/components/MediaOverlayMiniPlayer.vue'
+import PodcastMiniPlayer from '@/features/podcast/components/PodcastMiniPlayer.vue'
+import PodcastLiveRegion from '@/features/podcast/components/PodcastLiveRegion.vue'
+import PodcastDownloadWidget from '@/features/podcast/components/PodcastDownloadWidget.vue'
+import PodcastShortcutsDialog from '@/features/podcast/components/PodcastShortcutsDialog.vue'
+import { usePodcastPlayer } from '@/features/podcast/composables/usePodcastPlayer'
+import { usePodcastKeyboardShortcuts } from '@/features/podcast/composables/usePodcastKeyboardShortcuts'
+import { usePodcastDownloadBatches } from '@/features/podcast/composables/usePodcastDownloadBatches'
+import { usePodcastEvents } from '@/features/podcast/composables/usePodcastEvents'
import { Toaster } from '@/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
import { resolveRouteViewKey } from '@/router/view-key'
import LegalNotices from '@/components/legal/LegalNotices.vue'
+// Bottom-edge media and download surfaces publish their measured heights, so the toaster clears
+// whichever one currently sits highest.
+const TOASTER_OFFSET = {
+ right: '16px',
+ bottom:
+ 'max(16px, var(--podcast-mini-player-clearance, 0px), var(--tts-mini-player-clearance, 0px), var(--podcast-download-widget-clearance, 0px))',
+}
+const TOASTER_MOBILE_OFFSET = {
+ left: '16px',
+ right: '16px',
+ bottom:
+ 'max(16px, var(--podcast-mini-player-clearance, 0px), var(--tts-mini-player-clearance, 0px), var(--podcast-download-widget-clearance, 0px))',
+}
+
const { isOpen } = useChangePasswordDialog()
const themeStore = useThemeStore()
const route = useRoute()
const { user } = useAuth()
const { popupOpen, evaluate, syncPopup } = useWhatsNew()
+const podcastPlayer = APP_FEATURES.podcasts ? usePodcastPlayer() : null
+const podcastDownloads = APP_FEATURES.podcasts ? usePodcastDownloadBatches() : null
+const podcastEvents = APP_FEATURES.podcasts ? usePodcastEvents() : null
+
+if (APP_FEATURES.podcasts) usePodcastKeyboardShortcuts()
watch(
() => user.value,
- async (current) => {
- if (!current) return
+ async (current, previous) => {
+ if (previous && current?.id !== previous.id) {
+ await podcastPlayer?.resetForUserChange()
+ podcastDownloads?.resetForUserChange()
+ podcastEvents?.resetForUserChange()
+ }
+ if (!current) {
+ if (!previous) {
+ await podcastPlayer?.resetForUserChange()
+ podcastDownloads?.resetForUserChange()
+ podcastEvents?.resetForUserChange()
+ }
+ return
+ }
+ await podcastPlayer?.loadPreferences()
await evaluate()
syncPopup(route.name as string | undefined)
},
@@ -47,14 +90,20 @@ provide(
-
+
-
+
-
+
+
+
+
+
+
+
diff --git a/client/src/assets/theme/bridge.css b/client/src/assets/theme/bridge.css
index be1aa60b9..6afbcceea 100644
--- a/client/src/assets/theme/bridge.css
+++ b/client/src/assets/theme/bridge.css
@@ -35,6 +35,7 @@
--color-warning-foreground: var(--warning-foreground);
--color-info: var(--info);
--color-star-highlight: var(--star-highlight);
+ --color-info-foreground: var(--info-foreground);
--color-volume-read: var(--volume-read);
--color-volume-reading: var(--volume-reading);
--color-volume-unread: var(--volume-unread);
diff --git a/client/src/assets/theme/tokens.css b/client/src/assets/theme/tokens.css
index 8c29e0d6f..f87fcc7fb 100644
--- a/client/src/assets/theme/tokens.css
+++ b/client/src/assets/theme/tokens.css
@@ -39,6 +39,12 @@
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
+ --success: oklch(0.52 0.16 150);
+ --success-foreground: oklch(0.985 0 0);
+ --warning: oklch(0.57 0.15 75);
+ --warning-foreground: oklch(0.145 0 0);
+ --info: oklch(0.52 0.17 245);
+ --info-foreground: oklch(0.985 0 0);
--success: oklch(0.52 0.14 148);
--success-foreground: oklch(0.985 0 0);
@@ -133,6 +139,8 @@
/* Annotation pill hues — fixed, accent-independent. Sources get distinct
hues; status uses semantic tiers (Exact stays neutral via --muted). */
--pill-web: oklch(0.55 0.16 245);
+ --pill-ios: oklch(0.58 0.16 150);
+ --pill-watchos: oklch(0.58 0.18 35);
--pill-koreader: oklch(0.52 0.2 295);
--pill-kobo: oklch(0.55 0.1 195);
--pill-repaired: oklch(0.62 0.14 70);
@@ -297,6 +305,12 @@
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
+ --success: oklch(0.74 0.15 150);
+ --success-foreground: oklch(0.145 0 0);
+ --warning: oklch(0.78 0.14 75);
+ --warning-foreground: oklch(0.145 0 0);
+ --info: oklch(0.75 0.14 245);
+ --info-foreground: oklch(0.145 0 0);
--success: oklch(0.72 0.15 148);
--success-foreground: oklch(0.145 0 0);
@@ -360,6 +374,8 @@
/* Lifted lightness so pills read on dark surfaces */
--pill-web: oklch(0.72 0.14 245);
+ --pill-ios: oklch(0.75 0.14 150);
+ --pill-watchos: oklch(0.76 0.15 35);
--pill-koreader: oklch(0.72 0.17 295);
--pill-kobo: oklch(0.74 0.1 195);
--pill-repaired: oklch(0.78 0.13 70);
diff --git a/client/src/components/AppHeader.vue b/client/src/components/AppHeader.vue
index bf12d7fbe..de1b98e4a 100644
--- a/client/src/components/AppHeader.vue
+++ b/client/src/components/AppHeader.vue
@@ -14,6 +14,7 @@ import {
Trophy,
MoreVertical,
BadgeQuestionMark,
+ Headphones,
ExternalLink,
Sparkles,
Languages,
@@ -59,6 +60,7 @@ import { useThemeStore } from '@/stores/theme'
import { useLocaleStore } from '@/stores/locale'
import { getFormatColor } from '@/features/book/lib/format-colors'
import { useLegalNotices } from '@/components/legal/useLegalNotices'
+import { hasReadAlong, isReadAlongFormat, READ_ALONG_FORMAT_COLOR, READ_ALONG_FORMAT_TITLE } from '@/features/book/lib/file-capabilities'
const { t } = useI18n()
const router = useRouter()
@@ -356,8 +358,15 @@ function resultFormats(result: GlobalSearchResult): string[] {
return sortFormats([...formats])
}
-function formatBadgeStyle(fmt: string) {
- const color = getFormatColor(fmt)
+function formatHasReadAlong(fmt: string, result: GlobalSearchResult): boolean {
+ return isReadAlongFormat(
+ fmt,
+ result.files.some((file) => hasReadAlong(file)),
+ )
+}
+
+function formatBadgeStyle(fmt: string, result?: GlobalSearchResult) {
+ const color = result && formatHasReadAlong(fmt, result) ? READ_ALONG_FORMAT_COLOR : getFormatColor(fmt)
return {
color,
backgroundColor: `color-mix(in oklch, ${color} 10%, transparent)`,
@@ -451,10 +460,12 @@ function formatBadgeStyle(fmt: string) {
{{ fmt }}
+
@@ -578,10 +589,12 @@ function formatBadgeStyle(fmt: string) {
{{ fmt }}
+
diff --git a/client/src/components/AppLayout.vue b/client/src/components/AppLayout.vue
index 1ecc49ca2..eedaee3ba 100644
--- a/client/src/components/AppLayout.vue
+++ b/client/src/components/AppLayout.vue
@@ -32,6 +32,9 @@ const GRID_VIEW_NAMES = [
'SeriesDetailView',
'AuthorDetailView',
'LibrariesView',
+ 'PodcastLibrariesView',
+ 'PodcastCollectionView',
+ 'PodcastScopeView',
'SmartScopesView',
'CollectionsView',
]
@@ -53,9 +56,13 @@ const viewKey = computed(() => {
-
+
diff --git a/client/src/components/AppSidebar.vue b/client/src/components/AppSidebar.vue
index 75a832b94..9738aa7f3 100644
--- a/client/src/components/AppSidebar.vue
+++ b/client/src/components/AppSidebar.vue
@@ -1,28 +1,33 @@
-
-
-
+
+
+
@@ -211,6 +324,7 @@ onUnmounted(() => stopLibraryUploadListener())
Book Orbit
+
@@ -219,7 +333,7 @@ onUnmounted(() => stopLibraryUploadListener())
+ entity sections below can never push them out of the first screenful. -->
stopLibraryUploadListener())
-
-
+
+
stopLibraryUploadListener())
:empty-text="t('components.sidebar.noLibraries')"
:filter-label="t('components.sidebar.filterLibraries')"
:filter-placeholder="t('components.sidebar.filterLibrariesPlaceholder')"
- :see-all-label="t('components.sidebar.seeAllLibraries', { count: formatNumber(libraries.length) })"
+ :see-all-label="t('components.sidebar.seeAllLibraries', { count: formatNumber(bookLibraries.length) })"
:can-add="canManageLibraries"
:add-label="t('components.sidebar.newLibrary')"
@add="openCreateLibrary"
@@ -271,12 +385,12 @@ onUnmounted(() => stopLibraryUploadListener())
/>
-
+
stopLibraryUploadListener())
:empty-text="t('components.sidebar.noSmartScopes')"
:filter-label="t('components.sidebar.filterSmartScopes')"
:filter-placeholder="t('components.sidebar.filterSmartScopesPlaceholder')"
- :see-all-label="t('components.sidebar.seeAllSmartScopes', { count: formatNumber(smartScopes.length) })"
+ :see-all-label="t('components.sidebar.seeAllSmartScopes', { count: formatNumber(bookScopes.length) })"
can-add
:add-label="t('components.sidebar.newSmartScope')"
@add="openCreateSmartScope"
@@ -292,12 +406,12 @@ onUnmounted(() => stopLibraryUploadListener())
/>
-
+
stopLibraryUploadListener())
:empty-text="t('components.sidebar.noCollections')"
:filter-label="t('components.sidebar.filterCollections')"
:filter-placeholder="t('components.sidebar.filterCollectionsPlaceholder')"
- :see-all-label="t('components.sidebar.seeAllCollections', { count: formatNumber(collections.length) })"
+ :see-all-label="t('components.sidebar.seeAllCollections', { count: formatNumber(bookCollections.length) })"
can-add
:add-label="t('components.sidebar.newCollection')"
@add="openCreateCollection"
@@ -313,14 +427,77 @@ onUnmounted(() => stopLibraryUploadListener())
/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
stopLibraryUploadListener())
:empty-text="t('components.sidebar.noLibraries')"
:filter-label="t('components.sidebar.filterLibraries')"
:filter-placeholder="t('components.sidebar.filterLibrariesPlaceholder')"
- :see-all-label="t('components.sidebar.seeAllLibraries', { count: formatNumber(libraries.length) })"
+ :see-all-label="t('components.sidebar.seeAllLibraries', { count: formatNumber(bookLibraries.length) })"
:can-add="canManageLibraries"
:add-label="t('components.sidebar.newLibrary')"
:can-reorder="canManageLibraries"
- :persist-order="reorderLibraries"
+ :persist-order="persistLibraryOrder"
@add="openCreateLibrary"
@navigate="handleNavigate"
>
{{ scanPct(item.id) }}%
- {{ formatCompactNumber(item.bookCount) }}
+ {{ formatCompactNumber(entityCount(item) ?? 0) }}
@@ -354,7 +531,7 @@ onUnmounted(() => stopLibraryUploadListener())
section-id="smartScopes"
tour-id="sidebar-smartScopes"
:label="t('components.sidebar.smartScopes')"
- :items="smartScopes"
+ :items="bookScopes"
route-name="smartScope"
index-route-name="smart-scopes"
:active-id="activeSmartScopeId"
@@ -362,7 +539,7 @@ onUnmounted(() => stopLibraryUploadListener())
:empty-text="t('components.sidebar.noSmartScopes')"
:filter-label="t('components.sidebar.filterSmartScopes')"
:filter-placeholder="t('components.sidebar.filterSmartScopesPlaceholder')"
- :see-all-label="t('components.sidebar.seeAllSmartScopes', { count: formatNumber(smartScopes.length) })"
+ :see-all-label="t('components.sidebar.seeAllSmartScopes', { count: formatNumber(bookScopes.length) })"
can-add
:add-label="t('components.sidebar.newSmartScope')"
can-reorder
@@ -375,7 +552,7 @@ onUnmounted(() => stopLibraryUploadListener())
section-id="collections"
tour-id="sidebar-collections"
:label="t('components.sidebar.collections')"
- :items="collections"
+ :items="bookCollections"
route-name="collection"
index-route-name="collections"
:active-id="activeCollectionId"
@@ -383,7 +560,7 @@ onUnmounted(() => stopLibraryUploadListener())
:empty-text="t('components.sidebar.noCollections')"
:filter-label="t('components.sidebar.filterCollections')"
:filter-placeholder="t('components.sidebar.filterCollectionsPlaceholder')"
- :see-all-label="t('components.sidebar.seeAllCollections', { count: formatNumber(collections.length) })"
+ :see-all-label="t('components.sidebar.seeAllCollections', { count: formatNumber(bookCollections.length) })"
can-add
:add-label="t('components.sidebar.newCollection')"
can-reorder
@@ -392,6 +569,79 @@ onUnmounted(() => stopLibraryUploadListener())
@navigate="handleNavigate"
/>
+
+
+
+
+ {{ podcastImportPct(item.id) }}%
+ {{ formatCompactNumber(entityCount(item) ?? 0) }}
+
+
+
+
+
{{ podcastImportLabel(item.id) }}
+
+
+
+
+
+
+
+
diff --git a/client/src/components/ChipGroup.vue b/client/src/components/ChipGroup.vue
new file mode 100644
index 000000000..266c1b22c
--- /dev/null
+++ b/client/src/components/ChipGroup.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/client/src/components/EmptyState.vue b/client/src/components/EmptyState.vue
new file mode 100644
index 000000000..47f977ade
--- /dev/null
+++ b/client/src/components/EmptyState.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
{{ title }}
+
{{ hint }}
+
+
+
+
+
diff --git a/client/src/components/FilterChip.vue b/client/src/components/FilterChip.vue
new file mode 100644
index 000000000..252672477
--- /dev/null
+++ b/client/src/components/FilterChip.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
diff --git a/client/src/components/FormSheet.vue b/client/src/components/FormSheet.vue
new file mode 100644
index 000000000..ca612ae18
--- /dev/null
+++ b/client/src/components/FormSheet.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+ {{ title }}
+ {{ description }}
+
+
+
+
+
+
+ {{ error }}
+ {{ cancelLabel ?? t('common.cancel') }}
+
+ {{ submitLabel ?? t('common.save') }}
+
+
+
+
+
+
+
+
diff --git a/client/src/components/ListEndFooter.vue b/client/src/components/ListEndFooter.vue
new file mode 100644
index 000000000..745d8e7b2
--- /dev/null
+++ b/client/src/components/ListEndFooter.vue
@@ -0,0 +1,22 @@
+
+
+
+
+ {{ t('common.loading') }}
+
+ {{ t('podcast.library.allLoaded', { total: formatNumber(total) }) }}
+
+
+
diff --git a/client/src/components/ListSkeleton.vue b/client/src/components/ListSkeleton.vue
new file mode 100644
index 000000000..f244ecd9f
--- /dev/null
+++ b/client/src/components/ListSkeleton.vue
@@ -0,0 +1,16 @@
+
+
+
+
+ {{ label ?? t('common.loading') }}
+
+
+
diff --git a/client/src/components/LoadErrorPanel.vue b/client/src/components/LoadErrorPanel.vue
new file mode 100644
index 000000000..948fa18a4
--- /dev/null
+++ b/client/src/components/LoadErrorPanel.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
{{ message }}
+
{{ t('common.retry') }}
+
+
diff --git a/client/src/components/SelectionActionBar.vue b/client/src/components/SelectionActionBar.vue
index 4fa6a68f3..3b6eda104 100644
--- a/client/src/components/SelectionActionBar.vue
+++ b/client/src/components/SelectionActionBar.vue
@@ -242,7 +242,7 @@ watch(
>
@@ -484,7 +484,11 @@ watch(
-
+
diff --git a/client/src/components/ViewHeader.vue b/client/src/components/ViewHeader.vue
index ba28ccaa4..1de217ac4 100644
--- a/client/src/components/ViewHeader.vue
+++ b/client/src/components/ViewHeader.vue
@@ -304,7 +304,7 @@ function handleMobileSearchOpenUpdate(value: boolean) {
-
+
{{ t('components.viewHeader.display') }}
diff --git a/client/src/components/__tests__/EmptyState.spec.ts b/client/src/components/__tests__/EmptyState.spec.ts
new file mode 100644
index 000000000..3c6bf8aa0
--- /dev/null
+++ b/client/src/components/__tests__/EmptyState.spec.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import EmptyState from '../EmptyState.vue'
+
+describe('EmptyState', () => {
+ it('renders the icon, title, hint, and actions', () => {
+ const wrapper = mount(EmptyState, {
+ props: { icon: 'ListMusic', title: 'No episodes yet', hint: 'Follow a show to see its episodes here.' },
+ slots: { actions: 'Browse shows ' },
+ })
+
+ expect(wrapper.find('.lucide-list-music').exists()).toBe(true)
+ expect(wrapper.get('h2').text()).toBe('No episodes yet')
+ expect(wrapper.get('p').text()).toBe('Follow a show to see its episodes here.')
+ expect(wrapper.get('button').text()).toBe('Browse shows')
+ })
+
+ it('collapses to the title alone when nothing else is supplied', () => {
+ const wrapper = mount(EmptyState, { props: { title: 'Nothing here' } })
+
+ expect(wrapper.get('h2').text()).toBe('Nothing here')
+ expect(wrapper.get('h2').classes()).not.toContain('mt-4')
+ expect(wrapper.find('p').exists()).toBe(false)
+ expect(wrapper.find('button').exists()).toBe(false)
+ expect(wrapper.find('svg').exists()).toBe(false)
+ })
+})
diff --git a/client/src/components/__tests__/FormSheet.spec.ts b/client/src/components/__tests__/FormSheet.spec.ts
new file mode 100644
index 000000000..5253af006
--- /dev/null
+++ b/client/src/components/__tests__/FormSheet.spec.ts
@@ -0,0 +1,106 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
+import FormSheet from '../FormSheet.vue'
+
+const stubs = {
+ Sheet: { name: 'Sheet', props: ['open'], emits: ['update:open'], template: '
' },
+ SheetContent: { name: 'SheetContent', template: '
' },
+ SheetHeader: { name: 'SheetHeader', template: '
' },
+ SheetTitle: { name: 'SheetTitle', template: ' ' },
+ SheetDescription: { name: 'SheetDescription', template: '
' },
+ SheetFooter: { name: 'SheetFooter', template: '
' },
+}
+
+function mountSheet(props: Record = {}) {
+ return mount(FormSheet, {
+ props: { open: true, title: 'Edit details', ...props },
+ global: { stubs },
+ })
+}
+
+function footerButton(wrapper: ReturnType, label: string) {
+ return wrapper.findAll('button').find((button) => button.text() === label)!
+}
+
+describe('FormSheet', () => {
+ it('announces the open so a host can seed the form, and re-announces when the record changes', async () => {
+ // The watcher is immediate, so a sheet mounted closed reports one `closed` before anything else.
+ const wrapper = mountSheet({ open: false, resetKey: 1 })
+ expect(wrapper.emitted('opened')).toBeUndefined()
+ expect(wrapper.emitted('closed')).toHaveLength(1)
+
+ await wrapper.setProps({ open: true })
+ expect(wrapper.emitted('opened')).toHaveLength(1)
+
+ await wrapper.setProps({ resetKey: 2 })
+ expect(wrapper.emitted('opened')).toHaveLength(2)
+
+ await wrapper.setProps({ open: false })
+ expect(wrapper.emitted('closed')).toHaveLength(2)
+ })
+
+ it('closes straight away when nothing is unsaved', async () => {
+ const wrapper = mountSheet()
+
+ await footerButton(wrapper, 'Cancel').trigger('click')
+
+ expect(wrapper.emitted('update:open')).toEqual([[false]])
+ expect(wrapper.findComponent(ConfirmDialog).exists()).toBe(false)
+ })
+
+ it('asks before discarding unsaved edits, and stays open if the answer is no', async () => {
+ const wrapper = mountSheet({ dirty: true })
+
+ await footerButton(wrapper, 'Cancel').trigger('click')
+
+ expect(wrapper.emitted('update:open')).toBeUndefined()
+ const dialog = wrapper.findComponent(ConfirmDialog)
+ expect(dialog.props('title')).toBe('Discard changes?')
+
+ dialog.vm.$emit('cancel')
+ await wrapper.vm.$nextTick()
+ expect(wrapper.findComponent(ConfirmDialog).exists()).toBe(false)
+ expect(wrapper.emitted('update:open')).toBeUndefined()
+
+ await footerButton(wrapper, 'Cancel').trigger('click')
+ wrapper.findComponent(ConfirmDialog).vm.$emit('confirm')
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.emitted('update:open')).toEqual([[false]])
+ })
+
+ it('guards a backdrop or escape close the same way as the cancel button', async () => {
+ const wrapper = mountSheet({ dirty: true })
+
+ wrapper.findComponent({ name: 'Sheet' }).vm.$emit('update:open', false)
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.emitted('update:open')).toBeUndefined()
+ expect(wrapper.findComponent(ConfirmDialog).exists()).toBe(true)
+ })
+
+ it('disables both footer buttons while a write is in flight and neither closes the sheet', async () => {
+ const wrapper = mountSheet({ busy: true })
+
+ expect(footerButton(wrapper, 'Cancel').attributes('disabled')).toBeDefined()
+ expect(footerButton(wrapper, 'Save').attributes('disabled')).toBeDefined()
+
+ wrapper.findComponent({ name: 'Sheet' }).vm.$emit('update:open', false)
+ await wrapper.vm.$nextTick()
+ expect(wrapper.emitted('update:open')).toBeUndefined()
+ })
+
+ it('keeps cancel usable when only the submit is blocked', () => {
+ const wrapper = mountSheet({ submitDisabled: true })
+
+ expect(footerButton(wrapper, 'Cancel').attributes('disabled')).toBeUndefined()
+ expect(footerButton(wrapper, 'Save').attributes('disabled')).toBeDefined()
+ })
+
+ it('renders one error surface beside the footer actions', () => {
+ const wrapper = mountSheet({ error: 'Name is required' })
+
+ expect(wrapper.get('[role="alert"]').text()).toBe('Name is required')
+ })
+})
diff --git a/client/src/components/entity-index/EntityIndexView.vue b/client/src/components/entity-index/EntityIndexView.vue
index ac366a61c..fc766e1a1 100644
--- a/client/src/components/entity-index/EntityIndexView.vue
+++ b/client/src/components/entity-index/EntityIndexView.vue
@@ -5,6 +5,7 @@ import { ArrowUpDown, Plus, Search, X } from '@lucide/vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import AppIcon from '@/components/AppIcon.vue'
import { formatNumber } from '@/i18n/formatters'
+import { entityCount } from '@/lib/entity-count'
export interface EntityIndexItem {
id: number
@@ -12,6 +13,8 @@ export interface EntityIndexItem {
name: string
icon?: string | null
bookCount?: number | null
+ podcastCount?: number | null
+ type?: string
}
type SortField = 'custom' | 'name' | 'bookCount'
@@ -30,8 +33,10 @@ const props = withDefaults(
loading?: boolean
canAdd?: boolean
addLabel?: string
+ /** Indexes of non-book entities relabel the count sort to match what they actually count. */
+ countSortLabel?: string
}>(),
- { loading: false, canAdd: false, addLabel: undefined },
+ { loading: false, canAdd: false, addLabel: undefined, countSortLabel: undefined },
)
const emit = defineEmits<{ add: [] }>()
@@ -48,7 +53,7 @@ const order = ref('asc')
const sortLabels = computed>(() => ({
custom: t('components.entityIndex.sort.custom'),
name: t('components.entityIndex.sort.name'),
- bookCount: t('components.entityIndex.sort.bookCount'),
+ bookCount: props.countSortLabel ?? t('components.entityIndex.sort.bookCount'),
}))
const isDefaultSort = computed(() => sort.value === 'custom' && order.value === 'asc')
@@ -65,7 +70,7 @@ const sortedItems = computed(() => {
const field = sort.value
return [...matchedItems.value].sort((a, b) => {
if (field === 'name') return a.name.localeCompare(b.name) * direction
- if (field === 'bookCount') return ((a.bookCount ?? 0) - (b.bookCount ?? 0)) * direction
+ if (field === 'bookCount') return ((entityCount(a) ?? 0) - (entityCount(b) ?? 0)) * direction
return (a.displayOrder - b.displayOrder) * direction
})
})
@@ -101,6 +106,11 @@ function handleAdd() {
function itemRoute(id: number) {
return { name: props.routeName, params: { id } }
}
+
+function itemCountText(item: EntityIndexItem): string {
+ const count = entityCount(item) ?? 0
+ return item.type === 'podcasts' ? t('components.entityIndex.showCount', { count }) : t('components.entityIndex.bookCount', { count })
+}
@@ -212,8 +222,8 @@ function itemRoute(id: number) {
{{ item.name }}
-
- {{ t('components.entityIndex.bookCount', { count: item.bookCount }) }}
+
+ {{ itemCountText(item) }}
diff --git a/client/src/components/entity-index/__tests__/EntityIndexView.spec.ts b/client/src/components/entity-index/__tests__/EntityIndexView.spec.ts
new file mode 100644
index 000000000..e8a8180ba
--- /dev/null
+++ b/client/src/components/entity-index/__tests__/EntityIndexView.spec.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import EntityIndexView from '../EntityIndexView.vue'
+import type { EntityIndexItem } from '../EntityIndexView.vue'
+
+function mountView(items: EntityIndexItem[]) {
+ return mount(EntityIndexView, {
+ props: {
+ title: 'Libraries',
+ titleIcon: 'BookCopy',
+ items,
+ routeName: 'library',
+ fallbackIcon: 'BookCopy',
+ searchPlaceholder: 'Filter libraries...',
+ emptyTitle: 'No libraries yet',
+ emptyHint: 'Create a library to start adding books.',
+ },
+ global: {
+ stubs: {
+ RouterLink: { name: 'RouterLink', props: ['to'], template: ' ' },
+ Popover: { name: 'Popover', template: '
' },
+ PopoverTrigger: { name: 'PopoverTrigger', template: ' ' },
+ PopoverContent: { name: 'PopoverContent', template: '
' },
+ },
+ },
+ })
+}
+
+function makeItem(overrides: Partial): EntityIndexItem {
+ return { id: 1, displayOrder: 0, name: 'Library', icon: null, ...overrides }
+}
+
+describe('EntityIndexView', () => {
+ describe('counts', () => {
+ it('describes a book library by its book count', () => {
+ const wrapper = mountView([makeItem({ type: 'books', bookCount: 7 })])
+
+ expect(wrapper.text()).toContain('Books: 7')
+ })
+
+ it('describes a podcast library by its show count, which is not a book count', () => {
+ const wrapper = mountView([makeItem({ type: 'podcasts', bookCount: 0, podcastCount: 2 })])
+
+ expect(wrapper.text()).toContain('Shows: 2')
+ expect(wrapper.text()).not.toContain('Books: 0')
+ })
+
+ it('omits the count line for an entity that carries no count', () => {
+ const wrapper = mountView([makeItem({ name: 'Scope' })])
+
+ expect(wrapper.text()).not.toContain('Books:')
+ expect(wrapper.text()).not.toContain('Shows:')
+ })
+
+ it('sorts podcast libraries against book libraries by their own counts', async () => {
+ const wrapper = mountView([
+ makeItem({ id: 1, displayOrder: 0, name: 'Novels', type: 'books', bookCount: 1 }),
+ makeItem({ id: 2, displayOrder: 1, name: 'Podcasts', type: 'podcasts', bookCount: 0, podcastCount: 5 }),
+ ])
+
+ const sortByCount = wrapper.findAll('button').find((button) => button.text() === 'Book count')
+ if (!sortByCount) throw new Error('Expected a book count sort option')
+ await sortByCount.trigger('click')
+
+ expect(wrapper.findAll('a').map((link) => link.text())).toEqual(['NovelsBooks: 1', 'PodcastsShows: 5'])
+ })
+ })
+})
diff --git a/client/src/components/media/MediaScrubber.vue b/client/src/components/media/MediaScrubber.vue
new file mode 100644
index 000000000..23f7249a8
--- /dev/null
+++ b/client/src/components/media/MediaScrubber.vue
@@ -0,0 +1,237 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tooltipContent.primary }}
+
+ {{ tooltipContent.secondary }}
+
+
+
+
diff --git a/client/src/components/media/MediaTransport.vue b/client/src/components/media/MediaTransport.vue
new file mode 100644
index 000000000..bf11f7f69
--- /dev/null
+++ b/client/src/components/media/MediaTransport.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+ {{ skipBackBadge }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ skipForwardBadge }}
+
+
+
+
+
+
diff --git a/client/src/components/media/MediaVolumeControl.vue b/client/src/components/media/MediaVolumeControl.vue
new file mode 100644
index 000000000..ab0054da8
--- /dev/null
+++ b/client/src/components/media/MediaVolumeControl.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+ {{ percentLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ percentLabel }}
+
+
+
+
diff --git a/client/src/components/media/__tests__/MediaScrubber.spec.ts b/client/src/components/media/__tests__/MediaScrubber.spec.ts
new file mode 100644
index 000000000..e0197d284
--- /dev/null
+++ b/client/src/components/media/__tests__/MediaScrubber.spec.ts
@@ -0,0 +1,201 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import MediaScrubber from '../MediaScrubber.vue'
+import type { MediaScrubberMarker } from '../types'
+
+const RAIL_WIDTH = 200
+
+/** Test Utils builds pointer events as MouseEvent, whose clientX is read-only, so the event is constructed directly. */
+async function firePointer(rail: { element: Element }, type: string, clientX = 0) {
+ rail.element.dispatchEvent(new PointerEvent(type, { clientX, pointerId: 1, bubbles: true, cancelable: true }))
+ await nextTick()
+}
+
+function mountScrubber(props: Partial['$props']> = {}) {
+ const wrapper = mount(MediaScrubber, {
+ props: { current: 0, duration: 200, ariaLabel: 'Episode position', ...props },
+ })
+ const rail = wrapper.get('[role="slider"]')
+ // jsdom lays nothing out, so the rail geometry the pointer maths reads has to be stood up.
+ vi.spyOn(rail.element, 'getBoundingClientRect').mockReturnValue({
+ left: 0,
+ top: 0,
+ right: RAIL_WIDTH,
+ bottom: 44,
+ width: RAIL_WIDTH,
+ height: 44,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ } as DOMRect)
+ return { wrapper, rail }
+}
+
+describe('MediaScrubber pointer scrubbing', () => {
+ it('emits exactly one seek for a completed drag', async () => {
+ const { wrapper, rail } = mountScrubber()
+
+ await firePointer(rail, 'pointerdown', 20)
+ await firePointer(rail, 'pointermove', 60)
+ await firePointer(rail, 'pointermove', 150)
+ await firePointer(rail, 'pointerup', 150)
+
+ expect(wrapper.emitted('seek')).toEqual([[150]])
+ expect(wrapper.emitted('scrubStart')).toHaveLength(1)
+ expect(wrapper.emitted('scrubEnd')).toHaveLength(1)
+ })
+
+ it('previews the drag position without waiting for the parent to move', async () => {
+ const { wrapper, rail } = mountScrubber({ current: 0 })
+
+ await firePointer(rail, 'pointerdown', 100)
+
+ expect(wrapper.get('[data-testid="media-scrubber-thumb"]').attributes('style')).toContain('left: 50%')
+ expect(wrapper.get('[data-testid="media-scrubber-thumb"]').classes()).toContain('size-5')
+ })
+
+ it('abandons the drag without seeking when the pointer is cancelled', async () => {
+ const { wrapper, rail } = mountScrubber()
+
+ await firePointer(rail, 'pointerdown', 100)
+ await firePointer(rail, 'pointercancel')
+
+ expect(wrapper.emitted('seek')).toBeUndefined()
+ expect(wrapper.emitted('scrubEnd')).toHaveLength(1)
+ })
+
+ it('emits continuously while dragging when the caller needs live feedback', async () => {
+ const { wrapper, rail } = mountScrubber({ continuous: true, duration: 1, step: 0.05 })
+
+ await firePointer(rail, 'pointerdown', 0)
+ await firePointer(rail, 'pointermove', 100)
+ await firePointer(rail, 'pointerup', 100)
+
+ expect(wrapper.emitted('seek')).toEqual([[0], [0.5], [0.5]])
+ })
+})
+
+describe('MediaScrubber keyboard operation', () => {
+ it('steps by the arrow, page, and jump keys', async () => {
+ const { wrapper, rail } = mountScrubber({ current: 100 })
+
+ for (const key of ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End']) {
+ await rail.trigger('keydown', { key })
+ }
+
+ expect(wrapper.emitted('seek')).toEqual([[105], [95], [105], [95], [130], [70], [0], [200]])
+ })
+
+ it('clamps steps to the rail bounds', async () => {
+ const nearEnd = mountScrubber({ current: 198 })
+ const nearStart = mountScrubber({ current: 10 })
+
+ await nearEnd.rail.trigger('keydown', { key: 'ArrowRight' })
+ await nearStart.rail.trigger('keydown', { key: 'PageDown' })
+
+ expect(nearEnd.wrapper.emitted('seek')).toEqual([[200]])
+ expect(nearStart.wrapper.emitted('seek')).toEqual([[0]])
+ })
+
+ it('ignores keys it does not own', async () => {
+ const { wrapper, rail } = mountScrubber({ current: 100 })
+
+ await rail.trigger('keydown', { key: 'Tab' })
+
+ expect(wrapper.emitted('seek')).toBeUndefined()
+ })
+})
+
+describe('MediaScrubber rendering', () => {
+ it('renders chapter and bookmark markers, skipping the rail ends', () => {
+ const markers: MediaScrubberMarker[] = [
+ { position: 0, kind: 'chapter' },
+ { position: 50, kind: 'chapter' },
+ { position: 100, kind: 'chapter', active: true },
+ { position: 150, kind: 'bookmark' },
+ { position: 200, kind: 'chapter' },
+ ]
+ const { wrapper } = mountScrubber({ markers })
+
+ const rendered = wrapper.findAll('[data-testid="media-scrubber-marker"]')
+ expect(rendered).toHaveLength(3)
+ expect(rendered[0]!.classes()).toContain('bg-foreground/35')
+ expect(rendered[0]!.attributes('style')).toContain('left: 25%')
+ expect(rendered[1]!.classes()).toContain('bg-primary')
+ expect(rendered[2]!.classes()).toContain('bg-warning')
+ expect(rendered[2]!.attributes('data-kind')).toBe('bookmark')
+ })
+
+ it('shades buffered ranges and drops empty ones', () => {
+ const { wrapper } = mountScrubber({
+ buffered: [
+ { start: 0, end: 50 },
+ { start: 120, end: 120 },
+ { start: 150, end: 400 },
+ ],
+ })
+
+ const segments = wrapper.findAll('[data-testid="media-scrubber-buffered"]')
+ expect(segments).toHaveLength(2)
+ expect(segments[0]!.attributes('style')).toContain('width: 25%')
+ expect(segments[1]!.attributes('style')).toContain('left: 75%')
+ expect(segments[1]!.attributes('style')).toContain('width: 25%')
+ })
+
+ it('exposes slider semantics with the caller-composed value text', () => {
+ const { rail } = mountScrubber({ current: 90, ariaValueText: '1:30 elapsed of 3:20' })
+
+ expect(rail.attributes('aria-valuemin')).toBe('0')
+ expect(rail.attributes('aria-valuemax')).toBe('200')
+ expect(rail.attributes('aria-valuenow')).toBe('90')
+ expect(rail.attributes('aria-valuetext')).toBe('1:30 elapsed of 3:20')
+ expect(rail.attributes('aria-label')).toBe('Episode position')
+ expect(rail.attributes('tabindex')).toBe('0')
+ expect(rail.attributes('aria-disabled')).toBeUndefined()
+ })
+
+ it('shows the caller tooltip for the hovered position', async () => {
+ const tooltip = vi.fn<(seconds: number) => { primary: string; secondary: string }>((seconds) => ({
+ primary: `${seconds}s`,
+ secondary: 'Chapter two',
+ }))
+ const { wrapper, rail } = mountScrubber({ tooltip })
+
+ await firePointer(rail, 'pointermove', 50)
+
+ expect(tooltip).toHaveBeenCalledWith(50)
+ const bubble = wrapper.get('[data-testid="media-scrubber-tooltip"]')
+ expect(bubble.text()).toContain('50s')
+ expect(bubble.text()).toContain('Chapter two')
+
+ await firePointer(rail, 'pointerleave')
+ expect(wrapper.find('[data-testid="media-scrubber-tooltip"]').exists()).toBe(false)
+ })
+})
+
+describe('MediaScrubber inert states', () => {
+ it('renders an indeterminate rail when the duration is unknown', async () => {
+ const { wrapper, rail } = mountScrubber({ duration: 0 })
+
+ expect(rail.attributes('aria-disabled')).toBe('true')
+ expect(rail.attributes('tabindex')).toBe('-1')
+ expect(wrapper.find('[data-testid="media-scrubber-thumb"]').exists()).toBe(false)
+
+ await firePointer(rail, 'pointerdown', 100)
+ await rail.trigger('keydown', { key: 'ArrowRight' })
+
+ expect(wrapper.emitted('seek')).toBeUndefined()
+ expect(wrapper.emitted('scrubStart')).toBeUndefined()
+ })
+
+ it('ignores interaction while disabled', async () => {
+ const { wrapper, rail } = mountScrubber({ disabled: true })
+
+ await firePointer(rail, 'pointerdown', 100)
+ await rail.trigger('keydown', { key: 'End' })
+
+ expect(rail.attributes('aria-disabled')).toBe('true')
+ expect(wrapper.emitted('seek')).toBeUndefined()
+ })
+})
diff --git a/client/src/components/media/__tests__/MediaTransport.spec.ts b/client/src/components/media/__tests__/MediaTransport.spec.ts
new file mode 100644
index 000000000..d9bea8baf
--- /dev/null
+++ b/client/src/components/media/__tests__/MediaTransport.spec.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import MediaTransport from '../MediaTransport.vue'
+
+const labels = {
+ play: 'Play',
+ pause: 'Pause',
+ previous: 'Previous episode',
+ next: 'Next episode',
+ back: 'Back 15 seconds',
+ forward: 'Forward 30 seconds',
+}
+
+function mountTransport(props: Partial['$props']> = {}) {
+ return mount(MediaTransport, {
+ props: {
+ playing: false,
+ hasPrevious: true,
+ hasNext: true,
+ skipBackSeconds: 15,
+ skipForwardSeconds: 30,
+ labels,
+ ...props,
+ },
+ })
+}
+
+describe('MediaTransport', () => {
+ it('shows play, pause, and pending states on the main button', () => {
+ const idle = mountTransport()
+ expect(idle.find('.lucide-play').exists()).toBe(true)
+ expect(idle.find('[aria-label="Play"]').exists()).toBe(true)
+
+ const playing = mountTransport({ playing: true })
+ expect(playing.find('.lucide-pause').exists()).toBe(true)
+ expect(playing.find('[aria-label="Pause"]').exists()).toBe(true)
+
+ const pending = mountTransport({ playing: true, pending: true })
+ expect(pending.find('.lucide-loader-circle').exists()).toBe(true)
+ expect(pending.find('.lucide-pause').exists()).toBe(false)
+ expect(pending.find('.lucide-play').exists()).toBe(false)
+ })
+
+ it('keeps the main button operable while pending so a stalled stream can still be paused', async () => {
+ const wrapper = mountTransport({ playing: true, pending: true })
+
+ await wrapper.get('[aria-label="Pause"]').trigger('click')
+
+ expect(wrapper.emitted('toggle')).toHaveLength(1)
+ })
+
+ it('disables queue navigation with nothing to navigate to', () => {
+ const wrapper = mountTransport({ hasPrevious: false, hasNext: false })
+
+ expect(wrapper.get('[aria-label="Previous episode"]').attributes('disabled')).toBeDefined()
+ expect(wrapper.get('[aria-label="Next episode"]').attributes('disabled')).toBeDefined()
+ })
+
+ it('emits one event per control', async () => {
+ const wrapper = mountTransport()
+
+ await wrapper.get('[aria-label="Previous episode"]').trigger('click')
+ await wrapper.get('[aria-label="Back 15 seconds"]').trigger('click')
+ await wrapper.get('[aria-label="Play"]').trigger('click')
+ await wrapper.get('[aria-label="Forward 30 seconds"]').trigger('click')
+ await wrapper.get('[aria-label="Next episode"]').trigger('click')
+
+ expect(wrapper.emitted('previous')).toHaveLength(1)
+ expect(wrapper.emitted('skipBack')).toHaveLength(1)
+ expect(wrapper.emitted('toggle')).toHaveLength(1)
+ expect(wrapper.emitted('skipForward')).toHaveLength(1)
+ expect(wrapper.emitted('next')).toHaveLength(1)
+ })
+
+ it('badges the skip buttons with their own interval', () => {
+ const wrapper = mountTransport({ skipBackSeconds: 10, skipForwardSeconds: 45 })
+
+ expect(wrapper.get('[aria-label="Back 15 seconds"]').text()).toBe('10')
+ expect(wrapper.get('[aria-label="Forward 30 seconds"]').text()).toBe('45')
+ })
+
+ it('grows the main button for the full-player size', () => {
+ expect(mountTransport({ size: 'md' }).get('[aria-label="Play"]').classes()).toContain('size-10')
+ expect(mountTransport({ size: 'lg' }).get('[aria-label="Play"]').classes()).toContain('size-16')
+ })
+
+ it('rings the main button only when the caller opts in and playback is running', () => {
+ expect(mountTransport({ playing: true }).find('[data-testid="media-transport-pulse"]').exists()).toBe(false)
+ expect(mountTransport({ playing: false, pulse: true }).find('[data-testid="media-transport-pulse"]').exists()).toBe(false)
+ expect(mountTransport({ playing: true, pulse: true }).find('[data-testid="media-transport-pulse"]').exists()).toBe(true)
+ })
+})
diff --git a/client/src/components/media/__tests__/MediaVolumeControl.spec.ts b/client/src/components/media/__tests__/MediaVolumeControl.spec.ts
new file mode 100644
index 000000000..2ac4ac0b4
--- /dev/null
+++ b/client/src/components/media/__tests__/MediaVolumeControl.spec.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import MediaScrubber from '../MediaScrubber.vue'
+import MediaVolumeControl from '../MediaVolumeControl.vue'
+
+const popoverStubs = {
+ Popover: { template: '
' },
+ PopoverTrigger: { template: '
' },
+ PopoverContent: { template: '
' },
+}
+
+function mountControl(props: { volume: number; variant?: 'inline' | 'popover' }) {
+ return mount(MediaVolumeControl, { props, global: { stubs: popoverStubs } })
+}
+
+describe('MediaVolumeControl', () => {
+ it('cycles the speaker icon through silent, low, and high volume', () => {
+ expect(mountControl({ volume: 0 }).find('.lucide-volume-x').exists()).toBe(true)
+ expect(mountControl({ volume: 0.3 }).find('.lucide-volume-1').exists()).toBe(true)
+ expect(mountControl({ volume: 0.5 }).find('.lucide-volume-2').exists()).toBe(true)
+ })
+
+ it('labels the toggle by what the next click does', () => {
+ expect(mountControl({ volume: 0.8 }).get('button').attributes('aria-label')).toBe('Mute')
+
+ const muted = mountControl({ volume: 0 })
+ expect(muted.get('button').attributes('aria-label')).toBe('Unmute')
+ expect(muted.get('button').attributes('aria-pressed')).toBe('true')
+ })
+
+ it('leaves the mute decision to the caller', async () => {
+ const wrapper = mountControl({ volume: 0.8 })
+
+ await wrapper.get('button').trigger('click')
+
+ expect(wrapper.emitted('toggleMute')).toHaveLength(1)
+ expect(wrapper.emitted('update:volume')).toBeUndefined()
+ })
+
+ it('reports slider movement as a volume update', async () => {
+ const wrapper = mountControl({ volume: 0.8 })
+
+ wrapper.getComponent(MediaScrubber).vm.$emit('seek', 0.25)
+
+ expect(wrapper.emitted('update:volume')).toEqual([[0.25]])
+ })
+
+ it('drives the slider live so the level follows the drag', () => {
+ const scrubber = mountControl({ volume: 0.42 }).getComponent(MediaScrubber)
+
+ expect(scrubber.props('continuous')).toBe(true)
+ expect(scrubber.props('duration')).toBe(1)
+ expect(scrubber.props('current')).toBe(0.42)
+ expect(scrubber.props('ariaValueText')).toBe('42%')
+ })
+
+ it('renders the inline variant as one row with a percent readout', () => {
+ const wrapper = mountControl({ volume: 0.42, variant: 'inline' })
+
+ expect(wrapper.findAll('button')).toHaveLength(1)
+ expect(wrapper.text()).toContain('42%')
+ })
+
+ it('renders the popover variant behind an icon-only trigger', () => {
+ const wrapper = mountControl({ volume: 0.42, variant: 'popover' })
+
+ const buttons = wrapper.findAll('button')
+ expect(buttons).toHaveLength(2)
+ expect(buttons[0]!.attributes('aria-label')).toBe('Volume')
+ expect(buttons[0]!.text()).toBe('')
+ expect(buttons[1]!.attributes('aria-label')).toBe('Mute')
+ expect(wrapper.findComponent(MediaScrubber).exists()).toBe(true)
+ })
+})
diff --git a/client/src/components/media/types.ts b/client/src/components/media/types.ts
new file mode 100644
index 000000000..d0254d4ab
--- /dev/null
+++ b/client/src/components/media/types.ts
@@ -0,0 +1,25 @@
+export interface MediaBufferedRange {
+ start: number
+ end: number
+}
+
+export interface MediaScrubberMarker {
+ position: number
+ kind: 'chapter' | 'bookmark'
+ label?: string
+ active?: boolean
+}
+
+export interface MediaScrubberTooltip {
+ primary: string
+ secondary?: string
+}
+
+export interface MediaTransportLabels {
+ play: string
+ pause: string
+ previous: string
+ next: string
+ back: string
+ forward: string
+}
diff --git a/client/src/components/sidebar/SidebarEntitySection.vue b/client/src/components/sidebar/SidebarEntitySection.vue
index cd2d7e357..1764f7e61 100644
--- a/client/src/components/sidebar/SidebarEntitySection.vue
+++ b/client/src/components/sidebar/SidebarEntitySection.vue
@@ -10,6 +10,7 @@ import SidebarSectionHeader from '@/components/sidebar/SidebarSectionHeader.vue'
import SidebarSectionBody from '@/components/sidebar/SidebarSectionBody.vue'
import SidebarBadge from '@/components/sidebar/SidebarBadge.vue'
import { formatCompactNumber, formatNumber } from '@/i18n/formatters'
+import { entityCount } from '@/lib/entity-count'
import { useDraggableOrder } from '@/composables/useDraggableOrder'
import { useSidebarPrefs } from '@/composables/useSidebarPrefs'
@@ -19,6 +20,11 @@ export interface SidebarEntity {
name: string
icon?: string | null
bookCount?: number | null
+ podcastCount?: number | null
+ episodeCount?: number | null
+ /** Libraries discriminate on `type`; scopes and collections on `mediaType`. */
+ type?: string
+ mediaType?: 'books' | 'podcasts'
}
const props = withDefaults(
@@ -27,7 +33,8 @@ const props = withDefaults(
label: string
items: SidebarEntity[]
routeName: string
- indexRouteName: string
+ /** Omit when the section has no index page; the see-all link is then suppressed. */
+ indexRouteName?: string
activeId: number | null
fallbackIcon: string
emptyText: string
@@ -42,7 +49,15 @@ const props = withDefaults(
/** Rail popovers render the body regardless of the stored open state. */
alwaysOpen?: boolean
}>(),
- { canAdd: false, canReorder: false, addLabel: undefined, persistOrder: undefined, tourId: undefined, alwaysOpen: false },
+ {
+ indexRouteName: undefined,
+ canAdd: false,
+ canReorder: false,
+ addLabel: undefined,
+ persistOrder: undefined,
+ tourId: undefined,
+ alwaysOpen: false,
+ },
)
const emit = defineEmits<{ add: []; navigate: [] }>()
@@ -88,7 +103,7 @@ const matchedItems = computed(() => {
const visibleItems = computed(() => (Number.isFinite(capValue.value) ? matchedItems.value.slice(0, capValue.value) : matchedItems.value))
-const showSeeAll = computed(() => total.value > capValue.value)
+const showSeeAll = computed(() => Boolean(props.indexRouteName) && total.value > capValue.value)
/** Reordering is an explicit mode entered from the section menu. Grips are a rare action,
* so they stay out of the way until the user asks for them. */
@@ -145,12 +160,8 @@ function handleFilterKeydown(event: KeyboardEvent) {
}
}
-function itemRoute(id: number) {
- return { name: props.routeName, params: { id } }
-}
-
-function itemCount(item: SidebarEntity): number | null {
- return typeof item.bookCount === 'number' ? item.bookCount : null
+function itemRoute(item: SidebarEntity) {
+ return { name: props.routeName, params: { id: item.id } }
}
function gripLabel(item: SidebarEntity): string {
@@ -228,7 +239,7 @@ function onGripBlur(id: number) {
:key="item.id"
:is-active="activeId === item.id"
:tooltip="item.name"
- :to="itemRoute(item.id)"
+ :to="itemRoute(item)"
:icon="item.icon || fallbackIcon"
:fallback-icon="fallbackIcon"
:label="item.name"
@@ -236,7 +247,7 @@ function onGripBlur(id: number) {
>
- {{ formatCompactNumber(itemCount(item) ?? 0) }}
+ {{ formatCompactNumber(entityCount(item) ?? 0) }}
@@ -265,7 +276,7 @@ function onGripBlur(id: number) {
+
@@ -120,6 +124,8 @@ function handleShowJumpRailsChange(event: Event) {
:min="gridGapMin"
:max="gridGapMax"
:step="gridGapStep"
+ :aria-label="t('components.viewHeader.displayControls.gridGap')"
+ :aria-valuetext="t('components.viewHeader.displayControls.pixelValue', { value: gridGap })"
class="w-full accent-primary cursor-pointer"
/>
diff --git a/client/src/components/view-header/ViewHeaderMobileSearchSheet.vue b/client/src/components/view-header/ViewHeaderMobileSearchSheet.vue
index 48f87f163..70f7ad594 100644
--- a/client/src/components/view-header/ViewHeaderMobileSearchSheet.vue
+++ b/client/src/components/view-header/ViewHeaderMobileSearchSheet.vue
@@ -73,7 +73,9 @@ function handleKeydown(event: KeyboardEvent) {
/>
diff --git a/client/src/composables/__tests__/useMediaMode.spec.ts b/client/src/composables/__tests__/useMediaMode.spec.ts
new file mode 100644
index 000000000..7102d2b45
--- /dev/null
+++ b/client/src/composables/__tests__/useMediaMode.spec.ts
@@ -0,0 +1,116 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick, ref } from 'vue'
+import type { AuthUser } from '@bookorbit/types'
+
+const user = ref | null>(null)
+
+vi.mock('@/features/auth/composables/useAuth', () => ({
+ useAuth: () => ({ user, me: vi.fn<() => Promise>() }),
+}))
+
+async function loadModule() {
+ return import('../useMediaMode')
+}
+
+describe('useMediaMode', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ localStorage.clear()
+ user.value = { id: 7, settings: {} }
+ })
+
+ describe('routeMediaMode', () => {
+ it.each(['podcast-library', 'podcast-show', 'podcast-libraries'])('treats disabled %s routes as neutral', async (name) => {
+ const { routeMediaMode } = await loadModule()
+
+ expect(routeMediaMode(name)).toBeNull()
+ })
+
+ it.each([
+ 'library',
+ 'libraries',
+ 'smartScope',
+ 'smart-scopes',
+ 'collection',
+ 'collections',
+ 'authors',
+ 'author-detail',
+ 'series',
+ 'series-detail',
+ 'annotations',
+ 'book-detail',
+ 'book-dock',
+ 'tools-entity-manager',
+ ])('classifies %s as books', async (name) => {
+ const { routeMediaMode } = await loadModule()
+
+ expect(routeMediaMode(name)).toBe('books')
+ })
+
+ it.each(['dashboard', 'settings-podcasts', 'statistics', 'achievements', 'whats-new', undefined, null, Symbol('x')])(
+ 'treats %s as neutral',
+ async (name) => {
+ const { routeMediaMode } = await loadModule()
+
+ expect(routeMediaMode(name)).toBeNull()
+ },
+ )
+ })
+
+ describe('mediaModeHome', () => {
+ it('sends books to the dashboard', async () => {
+ const { mediaModeHome } = await loadModule()
+
+ expect(mediaModeHome('books', [{ id: 3 }])).toEqual({ name: 'dashboard' })
+ })
+
+ it('sends disabled podcast mode to the books dashboard', async () => {
+ const { mediaModeHome } = await loadModule()
+
+ expect(mediaModeHome('podcasts', [{ id: 3 }])).toEqual({ name: 'dashboard' })
+ })
+ })
+
+ describe('mode state', () => {
+ it('defaults to books and ignores a disabled podcast switch', async () => {
+ const { useMediaMode } = await loadModule()
+ const { mode, setMode } = useMediaMode()
+ await nextTick()
+
+ expect(mode.value).toBe('books')
+ setMode('podcasts')
+
+ expect(mode.value).toBe('books')
+ expect(localStorage.getItem('bookorbit:u7:sidebar:mode')).toBeNull()
+ })
+
+ it('restores the stored mode for the signed-in user and clamps junk', async () => {
+ localStorage.setItem('bookorbit:u7:sidebar:mode', '"podcasts"')
+ localStorage.setItem('bookorbit:u9:sidebar:mode', '"nonsense"')
+
+ const first = await loadModule()
+ const { mode } = first.useMediaMode()
+ await nextTick()
+ expect(mode.value).toBe('books')
+
+ user.value = { id: 9, settings: {} }
+ await nextTick()
+ expect(mode.value).toBe('books')
+ })
+
+ it('follows media routes and ignores neutral ones', async () => {
+ const { useMediaMode } = await loadModule()
+ const { mode, syncModeFromRoute } = useMediaMode()
+ await nextTick()
+
+ syncModeFromRoute('podcast-show')
+ expect(mode.value).toBe('books')
+
+ syncModeFromRoute('dashboard')
+ expect(mode.value).toBe('books')
+
+ syncModeFromRoute('authors')
+ expect(mode.value).toBe('books')
+ })
+ })
+})
diff --git a/client/src/composables/__tests__/useSidebarNav.spec.ts b/client/src/composables/__tests__/useSidebarNav.spec.ts
index 355c7aca0..c29bbf5ce 100644
--- a/client/src/composables/__tests__/useSidebarNav.spec.ts
+++ b/client/src/composables/__tests__/useSidebarNav.spec.ts
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Permission, type BrowseCounts } from '@bookorbit/types'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
-import { SIDEBAR_NAV_REGISTRY, isNavEntryAllowed, resolveNavEntry, useSidebarNav } from '../useSidebarNav'
+import { SIDEBAR_NAV_REGISTRY, entryVisibleInMode, isNavEntryAllowed, resolveNavEntry, useSidebarNav } from '../useSidebarNav'
const mocks = vi.hoisted(() => ({ permissions: new Set() }))
@@ -35,6 +35,7 @@ function makeContext(
outstandingRequestTotal: number
outstandingRequestLabel: string
browseCounts: BrowseCounts | null
+ firstPodcastLibraryId: number | null
}> = {},
) {
const permissions = overrides.permissions ?? []
@@ -44,11 +45,12 @@ function makeContext(
outstandingRequestTotal: overrides.outstandingRequestTotal ?? 0,
outstandingRequestLabel: overrides.outstandingRequestLabel ?? '',
browseCounts: overrides.browseCounts ?? null,
+ firstPodcastLibraryId: overrides.firstPodcastLibraryId ?? null,
}
}
-function makeRoute(name: string, params: Record = {}): RouteLocationNormalizedLoaded {
- return { name, params, query: {} } as unknown as RouteLocationNormalizedLoaded
+function makeRoute(name: string, params: Record = {}, query: Record = {}): RouteLocationNormalizedLoaded {
+ return { name, params, query } as unknown as RouteLocationNormalizedLoaded
}
function entry(id: string) {
@@ -70,10 +72,10 @@ describe('sidebar nav registry', () => {
expect(allowedIds(makeContext())).toEqual(['dashboard', 'authors', 'series', 'annotations'])
})
- it('places Dashboard, Book Dock, Requests and Tools in the primary zone, above the entity sections', () => {
+ it('places Dashboard, Book Dock, Requests, Tools and the podcast Queue in the primary zone, above the entity sections', () => {
const primary = SIDEBAR_NAV_REGISTRY.filter((candidate) => candidate.zone === 'primary').map((candidate) => candidate.id)
- expect(primary).toEqual(['dashboard', 'book-dock', 'book-requests', 'tools'])
+ expect(primary).toEqual(['dashboard', 'book-dock', 'book-requests', 'tools', 'podcast-queue'])
})
it('leaves Statistics and Achievements to the header', () => {
@@ -192,4 +194,30 @@ describe('sidebar nav registry', () => {
expect(['primary', 'browse']).toContain(candidate.zone)
}
})
+
+ it('hides the Queue until a podcast library exists', () => {
+ expect(allowedIds(makeContext())).not.toContain('podcast-queue')
+ expect(allowedIds(makeContext({ firstPodcastLibraryId: 4 }))).toContain('podcast-queue')
+ })
+
+ it('sends the Queue to the first podcast library queue tab, which spans all libraries', () => {
+ const context = makeContext({ firstPodcastLibraryId: 4 })
+
+ const resolved = resolveNavEntry(entry('podcast-queue'), context, makeRoute('dashboard'), 'Queue')
+
+ expect(resolved.to).toEqual({ name: 'podcast-library', params: { id: 4 }, query: { view: 'queue' } })
+ })
+
+ it('marks the Queue active only on the queue tab of a podcast library', () => {
+ expect(entry('podcast-queue').isActive(makeRoute('podcast-library', { id: '4' }, { view: 'queue' }))).toBe(true)
+ expect(entry('podcast-queue').isActive(makeRoute('podcast-library', { id: '4' }))).toBe(false)
+ })
+
+ it('keeps book destinations out of podcast mode and the Queue out of books mode', () => {
+ const podcastModeIds = SIDEBAR_NAV_REGISTRY.filter((candidate) => entryVisibleInMode(candidate, 'podcasts')).map((candidate) => candidate.id)
+ const booksModeIds = SIDEBAR_NAV_REGISTRY.filter((candidate) => entryVisibleInMode(candidate, 'books')).map((candidate) => candidate.id)
+
+ expect(podcastModeIds).toEqual(['dashboard', 'podcast-queue'])
+ expect(booksModeIds).toEqual(['dashboard', 'book-dock', 'book-requests', 'tools', 'authors', 'series', 'annotations'])
+ })
})
diff --git a/client/src/composables/__tests__/useSidebarPrefs.spec.ts b/client/src/composables/__tests__/useSidebarPrefs.spec.ts
index c97012da0..406a06235 100644
--- a/client/src/composables/__tests__/useSidebarPrefs.spec.ts
+++ b/client/src/composables/__tests__/useSidebarPrefs.spec.ts
@@ -35,8 +35,11 @@ describe('useSidebarPrefs', () => {
expect(parseSidebarConfig(undefined)).toEqual({
browse: { open: true },
libraries: { open: true, cap: 8 },
+ podcasts: { open: true, cap: 8 },
smartScopes: { open: true, cap: 8 },
collections: { open: true, cap: 8 },
+ podcastScopes: { open: true, cap: 8 },
+ podcastCollections: { open: true, cap: 8 },
})
})
@@ -140,8 +143,11 @@ describe('useSidebarPrefs', () => {
sections: {
browse: { open: true },
libraries: { open: true, cap: 8 },
+ podcasts: { open: true, cap: 8 },
smartScopes: { open: true, cap: 8 },
collections: { open: false, cap: 20 },
+ podcastScopes: { open: true, cap: 8 },
+ podcastCollections: { open: true, cap: 8 },
},
},
},
diff --git a/client/src/composables/useDraggableOrder.ts b/client/src/composables/useDraggableOrder.ts
index cb3c9ad28..4a2f231d9 100644
--- a/client/src/composables/useDraggableOrder.ts
+++ b/client/src/composables/useDraggableOrder.ts
@@ -9,6 +9,7 @@ interface UseDraggableOrderOptions {
source: Ref
persist: (order: { id: number; displayOrder: number }[]) => Promise
debounceMs?: number
+ onPersistError?: (error: unknown, order: { id: number; displayOrder: number }[]) => void
}
export type ReorderStatusKind = 'lifted' | 'moved' | 'dropped' | 'cancelled'
@@ -21,7 +22,7 @@ export interface ReorderStatus {
total: number
}
-export function useDraggableOrder({ source, persist, debounceMs = 600 }: UseDraggableOrderOptions) {
+export function useDraggableOrder({ source, persist, debounceMs = 600, onPersistError }: UseDraggableOrderOptions) {
const localItems = ref([]) as Ref
const liftedId = ref(null)
const status = ref | null>(null)
@@ -49,8 +50,9 @@ export function useDraggableOrder({ source, persist, de
const item = source.value.find((i) => i.id === id)
if (item) item.displayOrder = displayOrder
})
- } catch {
+ } catch (error) {
localItems.value = [...snapshot]
+ onPersistError?.(error, order)
}
}, debounceMs)
}
diff --git a/client/src/composables/useFilePicker.ts b/client/src/composables/useFilePicker.ts
new file mode 100644
index 000000000..5adaea866
--- /dev/null
+++ b/client/src/composables/useFilePicker.ts
@@ -0,0 +1,23 @@
+import { ref } from 'vue'
+
+/**
+ * The hidden ` ` dance: bind `input` to the element, call `open()` from a real
+ * button, and receive the chosen file. The element is cleared after every pick so choosing the same
+ * file twice in a row still fires a change event.
+ */
+export function useFilePicker(onSelect: (file: File) => void) {
+ const input = ref(null)
+
+ function open() {
+ input.value?.click()
+ }
+
+ function handleChange(event: Event) {
+ const element = event.target as HTMLInputElement
+ const file = element.files?.[0]
+ if (file) onSelect(file)
+ element.value = ''
+ }
+
+ return { input, open, handleChange }
+}
diff --git a/client/src/features/book/composables/useInfiniteScrollSentinel.ts b/client/src/composables/useInfiniteScrollSentinel.ts
similarity index 100%
rename from client/src/features/book/composables/useInfiniteScrollSentinel.ts
rename to client/src/composables/useInfiniteScrollSentinel.ts
diff --git a/client/src/composables/useMediaMode.ts b/client/src/composables/useMediaMode.ts
new file mode 100644
index 000000000..85a098bd1
--- /dev/null
+++ b/client/src/composables/useMediaMode.ts
@@ -0,0 +1,100 @@
+import { ref, watch, type Ref } from 'vue'
+import type { RouteLocationRaw } from 'vue-router'
+import { APP_FEATURES, type Library, type LibraryType } from '@bookorbit/types'
+import { storage } from '@/services/storage'
+import { useAuth } from '@/features/auth/composables/useAuth'
+import { userScopedKey } from '@/composables/useSidebarPrefs'
+
+const MODE_SUFFIX = 'mode'
+
+/** Routes that belong to exactly one medium flip the sidebar to it; anything else
+ * (dashboard, settings, statistics) is neutral and leaves the last mode in place. */
+const BOOK_ROUTE_NAMES = new Set([
+ 'library',
+ 'libraries',
+ 'smartScope',
+ 'smart-scopes',
+ 'collection',
+ 'collections',
+ 'authors',
+ 'author-detail',
+ 'series',
+ 'series-detail',
+ 'annotations',
+])
+
+const BOOK_ROUTE_PREFIXES = ['book-', 'tools-']
+
+export function routeMediaMode(routeName: unknown): LibraryType | null {
+ if (typeof routeName !== 'string') return null
+ if (APP_FEATURES.podcasts && routeName.startsWith('podcast-')) return 'podcasts'
+ if (BOOK_ROUTE_NAMES.has(routeName)) return 'books'
+ if (BOOK_ROUTE_PREFIXES.some((prefix) => routeName.startsWith(prefix))) return 'books'
+ return null
+}
+
+export function clampMediaMode(value: unknown): LibraryType {
+ return APP_FEATURES.podcasts && value === 'podcasts' ? 'podcasts' : 'books'
+}
+
+/** Where the switcher lands. Books share the dashboard as their home; podcasts go straight
+ * to the single library when there is only one, otherwise to the index. */
+export function mediaModeHome(target: LibraryType, podcastLibraries: Pick[]): RouteLocationRaw {
+ if (APP_FEATURES.podcasts && target === 'podcasts') {
+ const only = podcastLibraries.length === 1 ? podcastLibraries[0] : null
+ return only ? { name: 'podcast-library', params: { id: only.id } } : { name: 'podcast-libraries' }
+ }
+ return { name: 'dashboard' }
+}
+
+const mode: Ref = ref('books')
+
+let hydratedForUserId: number | null = null
+let watcherStarted = false
+
+function persist(userId: number | null): void {
+ if (userId === null) return
+ storage.set(userScopedKey(userId, MODE_SUFFIX), mode.value)
+}
+
+function hydrate(): void {
+ const { user } = useAuth()
+ const userId = user.value?.id ?? null
+ if (userId === null || userId === hydratedForUserId) return
+ hydratedForUserId = userId
+ mode.value = clampMediaMode(storage.get(userScopedKey(userId, MODE_SUFFIX), 'books'))
+}
+
+export function useMediaMode() {
+ const { user } = useAuth()
+
+ if (!watcherStarted) {
+ watcherStarted = true
+ watch(() => user.value?.id ?? null, hydrate, { immediate: true })
+ } else {
+ hydrate()
+ }
+
+ function setMode(next: LibraryType): void {
+ const availableMode = clampMediaMode(next)
+ if (mode.value === availableMode) return
+ mode.value = availableMode
+ persist(user.value?.id ?? null)
+ }
+
+ /** Route-driven sync: deep links land in the medium they belong to, so the
+ * sidebar never shows books navigation around a podcast screen. */
+ function syncModeFromRoute(routeName: unknown): void {
+ const routeMode = routeMediaMode(routeName)
+ if (routeMode) setMode(routeMode)
+ }
+
+ return { mode, setMode, syncModeFromRoute }
+}
+
+/** Test seam: clears the module-level state between specs. */
+export function resetMediaMode(): void {
+ hydratedForUserId = null
+ watcherStarted = false
+ mode.value = 'books'
+}
diff --git a/client/src/composables/useModal.ts b/client/src/composables/useModal.ts
index b35ae756d..c09c81ec5 100644
--- a/client/src/composables/useModal.ts
+++ b/client/src/composables/useModal.ts
@@ -1,4 +1,4 @@
-import { nextTick, onBeforeUnmount, onMounted, type Ref } from 'vue'
+import { nextTick, onBeforeUnmount, onMounted, watch, type Ref, type WatchStopHandle } from 'vue'
interface UseModalOptions {
/** The modal panel root. The focus trap cycles within it and it receives initial focus as a fallback. */
@@ -9,6 +9,8 @@ interface UseModalOptions {
disabled?: () => boolean
/** Element to focus on open; falls back to the first focusable element in the container. */
initialFocus?: Ref
+ /** Reactive open state for dialogs that are conditionally rendered inside an already-mounted component. */
+ active?: Readonly[>
}
const FOCUSABLE_SELECTOR = [
@@ -27,6 +29,8 @@ const FOCUSABLE_SELECTOR = [
export function useModal(options: UseModalOptions): void {
let previouslyFocused: HTMLElement | null = null
let previousOverflow = ''
+ let engaged = false
+ let stopWatching: WatchStopHandle | null = null
function focusable(): HTMLElement[] {
const root = options.container.value
@@ -57,7 +61,9 @@ export function useModal(options: UseModalOptions): void {
}
}
- onMounted(() => {
+ function engage(): void {
+ if (engaged) return
+ engaged = true
previouslyFocused = document.activeElement as HTMLElement | null
previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
@@ -66,11 +72,27 @@ export function useModal(options: UseModalOptions): void {
const target = options.initialFocus?.value ?? focusable()[0] ?? options.container.value
target?.focus()
})
- })
+ }
- onBeforeUnmount(() => {
+ function disengage(): void {
+ if (!engaged) return
+ engaged = false
window.removeEventListener('keydown', handleKeydown)
document.body.style.overflow = previousOverflow
previouslyFocused?.focus?.()
+ previouslyFocused = null
+ }
+
+ onMounted(() => {
+ if (options.active) {
+ stopWatching = watch(options.active, (active) => (active ? engage() : disengage()), { immediate: true, flush: 'post' })
+ } else {
+ engage()
+ }
+ })
+
+ onBeforeUnmount(() => {
+ stopWatching?.()
+ disengage()
})
}
diff --git a/client/src/composables/usePagedEntityMembers.ts b/client/src/composables/usePagedEntityMembers.ts
new file mode 100644
index 000000000..30f4b1c4e
--- /dev/null
+++ b/client/src/composables/usePagedEntityMembers.ts
@@ -0,0 +1,117 @@
+import { computed, onMounted, ref, type ComputedRef, type Ref } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { createRequestGeneration } from '@/lib/async'
+import { useInfiniteScrollSentinel } from './useInfiniteScrollSentinel'
+
+const DEFAULT_PAGE_SIZE = 50
+
+export interface PagedEntityMembersOptions] {
+ /** The entity whose members are paged, read from the route. */
+ entityId: Ref
+ /** The entity itself, resolved out of a shared list cache. Null until that cache has it. */
+ entity: ComputedRef
+ /** Whether the shared list cache has answered at all, which is what tells a missing id from an unloaded one. */
+ loaded: Ref
+ listError: Ref
+ fetchEntities: () => Promise
+ refreshEntities: () => Promise
+ fetchPage: (entityId: number, page: number, size: number) => Promise<{ items: TItem[]; total: number }>
+ /** Hands the authoritative total back to the cache that owns the entity, for its badge. */
+ applyTotal?: (entityId: number, total: number) => void
+ pageSize?: number
+ errorKeys: { load: string; loadMore: string }
+}
+
+/**
+ * A collection of shows and a smart scope of episodes are the same page: resolve an entity out of a
+ * shared list, then page its members with a generation guard, an infinite-scroll sentinel, and a
+ * first-page error that is fatal where an appended-page error is not.
+ *
+ * A failed first page re-reads the entity list before giving up, because the usual cause is that the
+ * entity was deleted or unshared in another tab: that answer is "not found", not "load failed".
+ */
+export function usePagedEntityMembers(options: PagedEntityMembersOptions) {
+ const { t } = useI18n()
+ const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE
+
+ const items = ref([]) as Ref
+ const total = ref(0)
+ const page = ref(0)
+ const loading = ref(true)
+ const failed = ref(false)
+ const appendFailed = ref(false)
+ const generation = createRequestGeneration()
+
+ const notFound = computed(() => options.loaded.value && !loading.value && options.entity.value === null)
+ const hasMore = computed(() => items.value.length < total.value)
+ /** Translated on read so a locale change updates a message already on screen. */
+ const error = computed(() => (failed.value ? t(options.errorKeys.load) : null))
+ const appendError = computed(() => (appendFailed.value ? t(options.errorKeys.loadMore) : null))
+
+ async function load(append = false): Promise {
+ if (!options.entity.value) return
+ // An append in flight must not swallow a reload the user just asked for, so only appends
+ // are skipped while busy; a reload supersedes whatever is running.
+ if (loading.value && append) return
+ const activeGeneration = generation.begin()
+ loading.value = true
+ if (append) appendFailed.value = false
+ else failed.value = false
+ try {
+ const requestedPage = append ? page.value + 1 : 0
+ const result = await options.fetchPage(options.entityId.value, requestedPage, pageSize)
+ if (!generation.isCurrent(activeGeneration)) return
+ items.value = append ? [...items.value, ...result.items] : result.items
+ total.value = result.total
+ page.value = requestedPage
+ options.applyTotal?.(options.entityId.value, result.total)
+ } catch {
+ if (!generation.isCurrent(activeGeneration)) return
+ if (!append) {
+ await options.refreshEntities()
+ if (!generation.isCurrent(activeGeneration) || !options.entity.value) return
+ }
+ if (append) appendFailed.value = true
+ else failed.value = true
+ } finally {
+ if (generation.isCurrent(activeGeneration)) loading.value = false
+ }
+ }
+
+ async function loadMore(): Promise {
+ await load(true)
+ }
+
+ async function loadRoute(): Promise {
+ const requestedId = options.entityId.value
+ loading.value = true
+ failed.value = false
+ await options.fetchEntities()
+ if (requestedId !== options.entityId.value) return
+ if (!options.loaded.value && options.listError.value) {
+ loading.value = false
+ failed.value = true
+ return
+ }
+ if (options.entity.value) {
+ await load()
+ return
+ }
+ loading.value = false
+ }
+
+ /** Drops a member the caller has already removed server-side, keeping the total and the badge in step. */
+ function removeItem(predicate: (item: TItem) => boolean): void {
+ const remaining = items.value.filter((item) => !predicate(item))
+ if (remaining.length === items.value.length) return
+ items.value = remaining
+ total.value = Math.max(0, total.value - 1)
+ options.applyTotal?.(options.entityId.value, total.value)
+ }
+
+ const { sentinel } = useInfiniteScrollSentinel({ loadMore, hasMore, loading })
+
+ onMounted(loadRoute)
+
+ return { items, total, loading, notFound, error, appendError, hasMore, sentinel, load, loadMore, loadRoute, removeItem }
+}
diff --git a/client/src/composables/useScrollOverflow.ts b/client/src/composables/useScrollOverflow.ts
new file mode 100644
index 000000000..718efa768
--- /dev/null
+++ b/client/src/composables/useScrollOverflow.ts
@@ -0,0 +1,54 @@
+import { onScopeDispose, ref, watch, type Ref } from 'vue'
+
+/**
+ * Whether a horizontally scrollable element still has content past its start or end edge.
+ *
+ * A row that hides its scrollbar gives a pointer no hint that anything is out there, so callers use
+ * these to fade the edge that has more behind it. Touch discovers the overflow by swiping; a mouse
+ * has nothing to go on.
+ */
+export function useScrollOverflow(target: Ref) {
+ const hasStartOverflow = ref(false)
+ const hasEndOverflow = ref(false)
+ let observer: ResizeObserver | null = null
+
+ function measure(): void {
+ const element = target.value
+ if (!element) {
+ hasStartOverflow.value = false
+ hasEndOverflow.value = false
+ return
+ }
+ // Sub-pixel layout leaves a fraction of a pixel behind on an element that is fully scrolled,
+ // which would otherwise read as overflow and leave the fade showing at the end of the row.
+ const remaining = element.scrollWidth - element.clientWidth - Math.abs(element.scrollLeft)
+ hasStartOverflow.value = Math.abs(element.scrollLeft) > 1
+ hasEndOverflow.value = remaining > 1
+ }
+
+ watch(
+ target,
+ (element, _previous, onCleanup) => {
+ observer?.disconnect()
+ observer = null
+ if (!element) return
+ element.addEventListener('scroll', measure, { passive: true })
+ if (typeof ResizeObserver !== 'undefined') {
+ observer = new ResizeObserver(measure)
+ observer.observe(element)
+ for (const child of element.children) observer.observe(child)
+ }
+ measure()
+ onCleanup(() => {
+ element.removeEventListener('scroll', measure)
+ observer?.disconnect()
+ observer = null
+ })
+ },
+ { immediate: true, flush: 'post' },
+ )
+
+ onScopeDispose(() => observer?.disconnect())
+
+ return { hasStartOverflow, hasEndOverflow, measure }
+}
diff --git a/client/src/composables/useSidebarNav.ts b/client/src/composables/useSidebarNav.ts
index 2959da398..6e3a635ed 100644
--- a/client/src/composables/useSidebarNav.ts
+++ b/client/src/composables/useSidebarNav.ts
@@ -1,11 +1,13 @@
import { computed, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, type RouteLocationNormalizedLoaded, type RouteLocationRaw } from 'vue-router'
-import { BookPlus, Highlighter, LayoutDashboard, Library, PackageOpen, Users, Wrench } from '@lucide/vue'
-import { Permission, type BrowseCounts, type SidebarSectionId } from '@bookorbit/types'
+import { BookPlus, Highlighter, LayoutDashboard, Library, ListMusic, PackageOpen, Users, Wrench } from '@lucide/vue'
+import { Permission, type BrowseCounts, type LibraryType, type SidebarSectionId } from '@bookorbit/types'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useBookDockSummary } from '@/features/book-dock/composables/useBookDockSummary'
+import { useLibraries } from '@/features/library/composables/useLibraries'
import { useBrowseCounts } from '@/composables/useBrowseCounts'
+import { useMediaMode } from '@/composables/useMediaMode'
export const SIDEBAR_ZONE_IDS = ['primary', 'browse'] as const
export type SidebarZoneId = (typeof SIDEBAR_ZONE_IDS)[number]
@@ -16,6 +18,7 @@ interface NavContext {
outstandingRequestTotal: number
outstandingRequestLabel: string
browseCounts: BrowseCounts | null
+ firstPodcastLibraryId: number | null
}
export interface SidebarNavBadge {
@@ -30,6 +33,8 @@ export interface SidebarNavEntry {
labelKey: string
icon: Component
zone: SidebarZoneId
+ /** Absent = shown in every media mode. */
+ modes?: readonly LibraryType[]
to: RouteLocationRaw | ((context: NavContext) => RouteLocationRaw)
isActive: (route: RouteLocationNormalizedLoaded) => boolean
/** Any-of: the entry is shown when the user holds at least one of these permissions. */
@@ -93,6 +98,7 @@ export const SIDEBAR_NAV_REGISTRY: readonly SidebarNavEntry[] = [
labelKey: 'components.appHeader.bookDock',
icon: PackageOpen,
zone: 'primary',
+ modes: ['books'],
to: { name: 'book-dock' },
isActive: (route) => route.name === 'book-dock',
permission: 'book_dock_access',
@@ -104,6 +110,7 @@ export const SIDEBAR_NAV_REGISTRY: readonly SidebarNavEntry[] = [
labelKey: 'bookRequests.title',
icon: BookPlus,
zone: 'primary',
+ modes: ['books'],
to: { name: 'book-requests' },
// The drawer routes are children of this one, so the row has to stay lit while one is open.
isActive: (route) => routeNameStartsWith(route, 'book-request'),
@@ -116,15 +123,28 @@ export const SIDEBAR_NAV_REGISTRY: readonly SidebarNavEntry[] = [
labelKey: 'components.sidebar.tools',
icon: Wrench,
zone: 'primary',
+ modes: ['books'],
to: (context) => ({ name: context.hasPermission('manage_libraries') ? 'tools-entity-manager' : 'tools-duplicate-books' }),
isActive: (route) => routeNameStartsWith(route, 'tools-'),
permission: ['manage_libraries', 'library_delete_books'],
},
+ {
+ id: 'podcast-queue',
+ labelKey: 'components.sidebar.queue',
+ icon: ListMusic,
+ zone: 'primary',
+ modes: ['podcasts'],
+ /** The queue spans libraries, so any podcast library's queue tab shows all of it. */
+ to: (context) => ({ name: 'podcast-library', params: { id: context.firstPodcastLibraryId ?? 0 }, query: { view: 'queue' } }),
+ isActive: (route) => route.name === 'podcast-library' && route.query.view === 'queue',
+ visible: (context) => context.firstPodcastLibraryId !== null,
+ },
{
id: 'authors',
labelKey: 'components.sidebar.authors',
icon: Users,
zone: 'browse',
+ modes: ['books'],
to: { name: 'authors' },
isActive: (route) => route.name === 'authors' || route.name === 'author-detail',
badge: browseBadge('authors'),
@@ -134,6 +154,7 @@ export const SIDEBAR_NAV_REGISTRY: readonly SidebarNavEntry[] = [
labelKey: 'components.sidebar.series',
icon: Library,
zone: 'browse',
+ modes: ['books'],
to: { name: 'series' },
isActive: (route) => route.name === 'series' || route.name === 'series-detail',
badge: browseBadge('series'),
@@ -143,12 +164,17 @@ export const SIDEBAR_NAV_REGISTRY: readonly SidebarNavEntry[] = [
labelKey: 'components.appHeader.annotations',
icon: Highlighter,
zone: 'browse',
+ modes: ['books'],
to: { name: 'annotations' },
isActive: (route) => route.name === 'annotations',
badge: browseBadge('annotations'),
},
]
+export function entryVisibleInMode(entry: SidebarNavEntry, mode: LibraryType): boolean {
+ return !entry.modes || entry.modes.includes(mode)
+}
+
export function isNavEntryAllowed(entry: SidebarNavEntry, context: NavContext): boolean {
if (entry.permission !== undefined) {
const required = Array.isArray(entry.permission) ? entry.permission : [entry.permission]
@@ -175,6 +201,8 @@ export function useSidebarNav(getOutstandingRequestTotal: () => number = () => 0
const { hasPermission } = usePermissions()
const { summary: bookDockSummary } = useBookDockSummary()
const { counts: browseCounts } = useBrowseCounts()
+ const { libraries } = useLibraries()
+ const { mode } = useMediaMode()
const context = computed(() => {
const outstandingRequestTotal = getOutstandingRequestTotal()
@@ -188,6 +216,7 @@ export function useSidebarNav(getOutstandingRequestTotal: () => number = () => 0
outstandingRequestTotal,
outstandingRequestLabel: t(outstandingRequestLabelKey, { count: outstandingRequestTotal }),
browseCounts: browseCounts.value,
+ firstPodcastLibraryId: libraries.value.find((library) => library.type === 'podcasts')?.id ?? null,
}
})
@@ -196,9 +225,9 @@ export function useSidebarNav(getOutstandingRequestTotal: () => number = () => 0
id: zoneId,
labelKey: ZONE_LABEL_KEYS[zoneId],
sectionId: ZONE_SECTION_IDS[zoneId],
- entries: SIDEBAR_NAV_REGISTRY.filter((entry) => entry.zone === zoneId && isNavEntryAllowed(entry, context.value)).map((entry) =>
- resolveNavEntry(entry, context.value, route, t(entry.labelKey)),
- ),
+ entries: SIDEBAR_NAV_REGISTRY.filter(
+ (entry) => entry.zone === zoneId && entryVisibleInMode(entry, mode.value) && isNavEntryAllowed(entry, context.value),
+ ).map((entry) => resolveNavEntry(entry, context.value, route, t(entry.labelKey))),
})).filter((zone) => zone.entries.length > 0),
)
diff --git a/client/src/composables/useSidebarPrefs.ts b/client/src/composables/useSidebarPrefs.ts
index fa44e27a8..c0bc4ea2b 100644
--- a/client/src/composables/useSidebarPrefs.ts
+++ b/client/src/composables/useSidebarPrefs.ts
@@ -16,7 +16,8 @@ import { useAuth } from '@/features/auth/composables/useAuth'
const PERSIST_DEBOUNCE_MS = 600
-/** Section open/closed state predating the account-scoped sidebarConfig blob. */
+/** Section open/closed state predating the account-scoped sidebarConfig blob.
+ * Sections added after that migration have no legacy key. */
const LEGACY_SECTION_KEYS: Partial> = {
libraries: 'bookorbit:sidebar:libraries',
smartScopes: 'bookorbit:sidebar:smart-scopes',
@@ -33,8 +34,11 @@ function defaultSections(): Record {
return {
browse: { open: true },
libraries: { open: true, cap: SIDEBAR_DEFAULT_CAP },
+ podcasts: { open: true, cap: SIDEBAR_DEFAULT_CAP },
smartScopes: { open: true, cap: SIDEBAR_DEFAULT_CAP },
collections: { open: true, cap: SIDEBAR_DEFAULT_CAP },
+ podcastScopes: { open: true, cap: SIDEBAR_DEFAULT_CAP },
+ podcastCollections: { open: true, cap: SIDEBAR_DEFAULT_CAP },
}
}
diff --git a/client/src/features/admin/SharedReadingInsightsPage.vue b/client/src/features/admin/SharedReadingInsightsPage.vue
index 282314e8c..35c3ec049 100644
--- a/client/src/features/admin/SharedReadingInsightsPage.vue
+++ b/client/src/features/admin/SharedReadingInsightsPage.vue
@@ -75,7 +75,7 @@ function genreLabel(genre: BroadReadingGenre): string {
}
function sourceLabel(source: string): string {
- if (['web', 'koreader', 'manual', 'kobo', 'unknown'].includes(source)) {
+ if (['web', 'ios', 'watchos', 'koreader', 'manual', 'kobo', 'unknown'].includes(source)) {
return t(`adminFeature.sharedInsights.sourceNames.${source}`)
}
return source
diff --git a/client/src/features/admin/__tests__/SharedReadingInsightsPage.spec.ts b/client/src/features/admin/__tests__/SharedReadingInsightsPage.spec.ts
index 7fe2539bd..2371dbe1b 100644
--- a/client/src/features/admin/__tests__/SharedReadingInsightsPage.spec.ts
+++ b/client/src/features/admin/__tests__/SharedReadingInsightsPage.spec.ts
@@ -17,7 +17,10 @@ const insightsMock = vi.hoisted(() => ({
booksCompleted: 1,
formatDistribution: [{ name: 'EPUB', readingSeconds: 3600 }],
genreDistribution: [],
- sourceCoverage: [{ source: 'web', readingSeconds: 3600, sessionsCount: 3 }],
+ sourceCoverage: [
+ { source: 'ios', readingSeconds: 2400, sessionsCount: 2 },
+ { source: 'watchos', readingSeconds: 1200, sessionsCount: 1 },
+ ],
trend: [],
},
},
@@ -47,6 +50,8 @@ describe('SharedReadingInsightsPage', () => {
expect(wrapper.text()).toContain('Sessions')
expect(wrapper.text()).toContain('Reading time')
expect(wrapper.text()).toContain('Daily reading trend')
+ expect(wrapper.text()).toContain('iOS app')
+ expect(wrapper.text()).toContain('Apple Watch')
expect(wrapper.text()).not.toContain('adminFeature.sharedInsights')
expect(wrapper.text()).not.toContain('Most-read books')
})
diff --git a/client/src/features/admin/lib/__tests__/permission-presets.spec.ts b/client/src/features/admin/lib/__tests__/permission-presets.spec.ts
index 71e8b47c5..4ec52b55b 100644
--- a/client/src/features/admin/lib/__tests__/permission-presets.spec.ts
+++ b/client/src/features/admin/lib/__tests__/permission-presets.spec.ts
@@ -4,11 +4,22 @@ import { Permission } from '@bookorbit/types'
import { PERMISSION_GROUPS, RESTRICTION_PERMISSIONS, detectPermissionSelection, permissionsInGroup, presetPermissions } from '../permission-presets'
describe('permission-presets', () => {
+ const podcastPermissions: Permission[] = [
+ Permission.PodcastManageFeeds,
+ Permission.PodcastDownload,
+ Permission.PodcastEditMetadata,
+ Permission.PodcastManageRetention,
+ Permission.PodcastPurge,
+ ]
+
// The user form renders permissions by walking the groups, so a permission missing from every
// group is not merely unlabelled: nobody but a superuser can ever hold it.
- it('offers every permission in exactly one group', () => {
+ it('offers every enabled permission in exactly one group', () => {
const grouped = [...PERMISSION_GROUPS.flatMap(permissionsInGroup), ...RESTRICTION_PERMISSIONS]
- expect([...grouped].sort()).toEqual(Object.values(Permission).sort())
+ const enabled = Object.values(Permission).filter((permission) => !podcastPermissions.includes(permission))
+
+ expect([...grouped].sort()).toEqual(enabled.sort())
+ expect(PERMISSION_GROUPS.map((group) => group.id)).not.toContain('podcasts')
})
it('keeps demo_restricted separate from granting permissions', () => {
diff --git a/client/src/features/admin/lib/permission-presets.ts b/client/src/features/admin/lib/permission-presets.ts
index ec68ea57a..900ba6e8c 100644
--- a/client/src/features/admin/lib/permission-presets.ts
+++ b/client/src/features/admin/lib/permission-presets.ts
@@ -1,4 +1,4 @@
-import { Permission } from '@bookorbit/types'
+import { APP_FEATURES, Permission } from '@bookorbit/types'
export interface PermissionGroup {
id: string
@@ -23,6 +23,15 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
use: [Permission.BookRequestAccess, Permission.BookRequestAutoApprove, Permission.BookRequestSelfFulfill],
manage: [Permission.ManageBookRequests],
},
+ ...(APP_FEATURES.podcasts
+ ? [
+ {
+ id: 'podcasts',
+ use: [Permission.PodcastDownload],
+ manage: [Permission.PodcastManageFeeds, Permission.PodcastEditMetadata, Permission.PodcastManageRetention, Permission.PodcastPurge],
+ },
+ ]
+ : []),
{
id: 'devices',
use: [Permission.KoboSync, Permission.KoreaderSync, Permission.OpdsAccess],
@@ -80,6 +89,7 @@ export function isRestrictionPermission(permissionName: string): boolean {
*/
const STANDARD_PRESET: Permission[] = [
Permission.LibraryDownload,
+ ...(APP_FEATURES.podcasts ? [Permission.PodcastDownload] : []),
Permission.KoboSync,
Permission.KoreaderSync,
Permission.HardcoverSync,
diff --git a/client/src/features/audit/AuditLogPage.vue b/client/src/features/audit/AuditLogPage.vue
index 565d4b79e..25b13972b 100644
--- a/client/src/features/audit/AuditLogPage.vue
+++ b/client/src/features/audit/AuditLogPage.vue
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'
import { useMediaQuery } from '@vueuse/core'
import type { AuditLogEntry } from '@bookorbit/types'
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, RefreshCw, Search, SlidersHorizontal, X } from '@lucide/vue'
-import { formatDateTime, formatRelativeTime } from '@/i18n/formatters'
+import { formatDateTime, formatRelativeTimeFromNow } from '@/i18n/formatters'
import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue'
import AuditActionPicker from './AuditActionPicker.vue'
import AuditActorPicker from './AuditActorPicker.vue'
@@ -52,14 +52,7 @@ const activeFilterChips = computed(() => {
const hasFilters = computed(() => activeFilterChips.value.length > 0)
function relativeTimestamp(value: string): string {
- const differenceMs = new Date(value).getTime() - currentTime.value
- const absoluteMs = Math.abs(differenceMs)
- if (absoluteMs < 60_000) return formatRelativeTime(Math.round(differenceMs / 1000), 'second')
- if (absoluteMs < 3_600_000) return formatRelativeTime(Math.round(differenceMs / 60_000), 'minute')
- if (absoluteMs < 86_400_000) return formatRelativeTime(Math.round(differenceMs / 3_600_000), 'hour')
- if (absoluteMs < 2_592_000_000) return formatRelativeTime(Math.round(differenceMs / 86_400_000), 'day')
- if (absoluteMs < 31_536_000_000) return formatRelativeTime(Math.round(differenceMs / 2_592_000_000), 'month')
- return formatRelativeTime(Math.round(differenceMs / 31_536_000_000), 'year')
+ return formatRelativeTimeFromNow(value, { now: currentTime.value })
}
function exactTimestamp(value: string): string {
diff --git a/client/src/features/audit/__tests__/audit-display.spec.ts b/client/src/features/audit/__tests__/audit-display.spec.ts
index 9e26a6b3d..546b33447 100644
--- a/client/src/features/audit/__tests__/audit-display.spec.ts
+++ b/client/src/features/audit/__tests__/audit-display.spec.ts
@@ -24,6 +24,7 @@ describe('audit display helpers', () => {
[AuditAction.BookDelete, 'books'],
[AuditAction.UserUpdate, 'users'],
[AuditAction.LibraryUpdate, 'libraries'],
+ [AuditAction.PodcastUpdate, 'libraries'],
[AuditAction.CollectionUpdate, 'collections'],
[AuditAction.KoboDeviceRename, 'integrations'],
[AuditAction.AppSettingsUpdate, 'settings'],
diff --git a/client/src/features/audit/audit-display.ts b/client/src/features/audit/audit-display.ts
index dae87b25a..c42040bac 100644
--- a/client/src/features/audit/audit-display.ts
+++ b/client/src/features/audit/audit-display.ts
@@ -7,7 +7,7 @@ export function getAuditCategory(action: string): AuditCategory {
if (action.startsWith('auth.') || action.startsWith('magic_link.')) return 'authentication'
if (action.startsWith('book.')) return 'books'
if (action.startsWith('user.') || action.startsWith('reading_insights.')) return 'users'
- if (action.startsWith('library.')) return 'libraries'
+ if (action.startsWith('library.') || action.startsWith('podcast.')) return 'libraries'
if (action.startsWith('collection.') || action.startsWith('smart_scope.')) return 'collections'
if (action.startsWith('kobo.') || action.startsWith('email.')) return 'integrations'
if (action.startsWith('app_settings.') || action.startsWith('entity_manager.') || action.startsWith('author.')) return 'settings'
diff --git a/client/src/features/audit/audit-resources.ts b/client/src/features/audit/audit-resources.ts
index 294962560..b016134aa 100644
--- a/client/src/features/audit/audit-resources.ts
+++ b/client/src/features/audit/audit-resources.ts
@@ -18,6 +18,8 @@ const domainByResource: Record = {
[AuditResource.User]: 'people',
[AuditResource.Library]: 'content',
[AuditResource.Book]: 'content',
+ [AuditResource.Podcast]: 'content',
+ [AuditResource.PodcastEpisode]: 'content',
[AuditResource.Collection]: 'content',
[AuditResource.SmartScope]: 'content',
[AuditResource.BookDockFile]: 'content',
diff --git a/client/src/features/auth/composables/__tests__/useAuth.spec.ts b/client/src/features/auth/composables/__tests__/useAuth.spec.ts
index ca0f45991..956a22dd4 100644
--- a/client/src/features/auth/composables/__tests__/useAuth.spec.ts
+++ b/client/src/features/auth/composables/__tests__/useAuth.spec.ts
@@ -127,4 +127,20 @@ describe('useAuth', () => {
expect(setAccessTokenMock).toHaveBeenCalledWith(null)
expect(routerPushMock).toHaveBeenCalledWith('/login')
})
+ it('finishes local sign-out when the server is unavailable', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network unavailable')))
+ const { useAuth } = await import('../useAuth')
+ await expect(useAuth().logout()).resolves.toBeUndefined()
+ expect(setAccessTokenMock).toHaveBeenCalledWith(null)
+ expect(routerPushMock).toHaveBeenCalledWith('/login')
+ })
+
+ it('uses the local login route after successful logout', async () => {
+ const readBody = vi.fn<() => unknown>()
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: readBody } as unknown as Response))
+ const { useAuth } = await import('../useAuth')
+ await useAuth().logout()
+ expect(readBody).not.toHaveBeenCalled()
+ expect(routerPushMock).toHaveBeenCalledWith('/login')
+ })
})
diff --git a/client/src/features/auth/composables/useAuth.ts b/client/src/features/auth/composables/useAuth.ts
index e1699dc22..d51d36d6e 100644
--- a/client/src/features/auth/composables/useAuth.ts
+++ b/client/src/features/auth/composables/useAuth.ts
@@ -211,19 +211,11 @@ export function useAuth() {
async function logout(): Promise {
try {
- const res = await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' })
- clearAuth()
- if (res.ok) {
- const data = await res.json().catch(() => ({}))
- if (data?.logoutUrl) {
- window.location.href = data.logoutUrl
- return
- }
- }
- } catch {
+ await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => undefined)
+ } finally {
clearAuth()
+ router.push('/login')
}
- router.push('/login')
}
async function loginWithMagicLink(token: string): Promise {
diff --git a/client/src/features/book-dock/components/__tests__/BookDockFileRow.spec.ts b/client/src/features/book-dock/components/__tests__/BookDockFileRow.spec.ts
index 9825fdf70..0e553a2e1 100644
--- a/client/src/features/book-dock/components/__tests__/BookDockFileRow.spec.ts
+++ b/client/src/features/book-dock/components/__tests__/BookDockFileRow.spec.ts
@@ -108,7 +108,7 @@ describe('BookDockFileRow with a multi-file unit', () => {
/** The anchor row's own size is one track's; the row must show what the whole unit weighs. */
it('sums the unit size rather than reporting the primary file alone', () => {
- expect(mountUnitRow({ unitFiles: TRACKS }).text()).toContain('2.5 KB')
+ expect(mountUnitRow({ unitFiles: TRACKS }).text()).toContain('2.5 kB')
})
it('lists the unit files in order with their roles once expanded', () => {
diff --git a/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.spec.ts b/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.spec.ts
index b0ca9e6c3..2a6e4c295 100644
--- a/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.spec.ts
+++ b/client/src/features/book-metadata-fetch/components/BookMetadataFetchWidget.spec.ts
@@ -165,8 +165,7 @@ describe('BookMetadataFetchWidget', () => {
})
await nextTick()
- expect(toastWarning).toHaveBeenCalledOnce()
- expect(toastWarning).toHaveBeenCalledWith('Author enrichment done - 1 processed, 1 failed')
+ expect(toastWarning).toHaveBeenCalledExactlyOnceWith('Author enrichment done - 1 processed, 1 failed')
expect(toastSuccess).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('Author enrichment finished')
})
diff --git a/client/src/features/book-requests/__tests__/ReleaseFileInspectionPanel.spec.ts b/client/src/features/book-requests/__tests__/ReleaseFileInspectionPanel.spec.ts
index 8d2f71355..31a4460fc 100644
--- a/client/src/features/book-requests/__tests__/ReleaseFileInspectionPanel.spec.ts
+++ b/client/src/features/book-requests/__tests__/ReleaseFileInspectionPanel.spec.ts
@@ -38,7 +38,7 @@ describe('ReleaseFileInspectionPanel', () => {
const wrapper = mountPanel()
expect(wrapper.text()).toContain('Pride and Prejudice/Pride and Prejudice.epub')
- expect(wrapper.text()).toContain('2 KB')
+ expect(wrapper.text()).toContain('2 kB')
expect(wrapper.get('[role="status"]').text()).toContain('1 file')
})
diff --git a/client/src/features/book/components/BookCoverCard.vue b/client/src/features/book/components/BookCoverCard.vue
index b0608fab8..b6bc35448 100644
--- a/client/src/features/book/components/BookCoverCard.vue
+++ b/client/src/features/book/components/BookCoverCard.vue
@@ -12,6 +12,7 @@ import {
Eye,
FolderInput,
FolderPlus,
+ Headphones,
Image,
Lock,
LockOpen,
@@ -52,6 +53,7 @@ import BookCoverArtwork from './BookCoverArtwork.vue'
import BookCoverSurface from './BookCoverSurface.vue'
import { fetchAuthors } from '@/features/author/api/author'
import { useI18n } from 'vue-i18n'
+import { hasReadAlong, READ_ALONG_FORMAT_COLOR, READ_ALONG_FORMAT_TITLE } from '@/features/book/lib/file-capabilities'
const { t } = useI18n()
@@ -81,6 +83,8 @@ const authorQuery = computed(() => props.book.authors[0] ?? null)
const readableFiles = computed(() => props.book.files.filter((f) => f.format && READER_OPENABLE_FORMATS.has(f.format)))
const primaryFile = computed(() => readableFiles.value.find((f) => f.role === 'primary') ?? readableFiles.value[0] ?? null)
const mediaProfile = computed(() => getBookMediaProfile(readableFiles.value))
+const readAlongFile = computed(() => readableFiles.value.find((file) => hasReadAlong(file)) ?? null)
+const formatOverlayFile = computed(() => readAlongFile.value ?? primaryFile.value)
const isAudiobook = computed(() => mediaProfile.value.primaryMediaKind === 'audiobook')
const isComic = computed(() => mediaProfile.value.primaryMediaKind === 'comic')
@@ -132,7 +136,7 @@ const showSendDialog = ref(false)
const hasProgress = computed(() => props.book.readingProgress != null && props.book.readingProgress > 0)
const showProgressBar = computed(() => cardOverlays.value.includes('progress-bar') && hasProgress.value)
-const showFormatOverlay = computed(() => cardOverlays.value.includes('format') && primaryFile.value?.format != null)
+const showFormatOverlay = computed(() => cardOverlays.value.includes('format') && formatOverlayFile.value?.format != null)
const showRatingOverlay = computed(() => cardOverlays.value.includes('rating') && props.book.rating != null)
const showLockStatusPill = computed(() => cardOverlays.value.includes('lock-status') && !props.selectionMode && !isMissing.value)
const metadataLocked = computed(() => props.book.hasMetadataLocks)
@@ -159,6 +163,13 @@ const ratingColor = computed(() => {
return '#059669'
})
+const formatOverlayStyle = computed(() => {
+ const file = formatOverlayFile.value
+ if (!file?.format) return {}
+ const color = hasReadAlong(file) ? READ_ALONG_FORMAT_COLOR : getFormatColor(file.format)
+ return { backgroundColor: `${color}e6` }
+})
+
const coverLoaded = ref(false)
const coverFailed = ref(false)
const coverImageRatio = ref(null)
@@ -491,14 +502,16 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
- {{ primaryFile!.format!.toUpperCase() }}
+ {{ formatOverlayFile!.format!.toUpperCase() }}
+
diff --git a/client/src/features/book/components/BookDownloadButton.vue b/client/src/features/book/components/BookDownloadButton.vue
index 0d6b797cd..5563b721a 100644
--- a/client/src/features/book/components/BookDownloadButton.vue
+++ b/client/src/features/book/components/BookDownloadButton.vue
@@ -17,9 +17,10 @@ const props = defineProps<{
bookId: number
}>()
-const { isDownloading, downloadFile, exportBooks } = useBookDownload()
+const { isDownloading, downloadFile, downloadAudiolessEpub, exportBooks } = useBookDownload()
const readableFiles = computed(() => props.files.filter((f) => f.format && READER_OPENABLE_FORMATS.has(f.format)))
+const audiolessEpubFiles = computed(() => readableFiles.value.filter((f) => f.format?.toLowerCase() === 'epub' && f.mediaOverlay?.available))
const primaryFile = computed(() => readableFiles.value.find((f) => f.role === 'primary') ?? readableFiles.value[0] ?? null)
@@ -32,7 +33,7 @@ const nonAudioFiles = computed(() => readableFiles.value.filter((f) => FORMAT_TO
const hasMultiple = computed(() => {
if (isMultiTrackAudio.value) return nonAudioFiles.value.length > 0
- return readableFiles.value.length > 1
+ return readableFiles.value.length > 1 || audiolessEpubFiles.value.length > 0
})
function formatBadgeStyle(fmt: string) {
@@ -52,6 +53,14 @@ function handleFileDownload(file: BookDetailFile) {
downloadFile(file.id)
}
+function handleAudiolessEpubDownload(file: BookDetailFile) {
+ downloadAudiolessEpub(file.id)
+}
+
+function isAudiolessEpubCandidate(file: BookDetailFile): boolean {
+ return file.format?.toLowerCase() === 'epub' && file.mediaOverlay?.available === true
+}
+
function handleExportAll() {
exportBooks([props.bookId], true)
}
@@ -90,7 +99,7 @@ function handleExportPrimary() {
-
+
@@ -110,6 +119,19 @@ function handleExportPrimary() {
>
{{ formatFileSize(file.sizeBytes) }}
+
+ EPUB
+ KOReader EPUB
+ no audio
+
@@ -128,6 +150,20 @@ function handleExportPrimary() {
{{ formatFileSize(file.sizeBytes) }}
{{ t('book.file.primary') }}
+
+ EPUB
+ KOReader EPUB
+ no audio
+ Primary
+
| undefined {
+ return hasReadAlong(file) ? { backgroundColor: READ_ALONG_FORMAT_COLOR } : undefined
+}
+
+function formatButtonTooltip(file: BookFileRef): string {
+ return hasReadAlong(file)
+ ? t('book.actions.openReadAlong')
+ : t('book.actions.openAs', { format: file.format?.toUpperCase() ?? t('book.unknownFormat') })
+}
+
function peekPrimaryFile() {
if (!primaryFile.value || isMissing.value) return
openFile(primaryFile.value, 'peek')
@@ -376,24 +395,32 @@ function handleRowClick(event: MouseEvent) {
{{ primaryFile.format ?? '?' }}
+
- {{ t('book.actions.openAs', { format: primaryFile.format?.toUpperCase() ?? t('book.unknownFormat') }) }}
+ {{ formatButtonTooltip(primaryFile) }}
{{ file.format ?? '?' }}
+
- {{ t('book.actions.openAs', { format: file.format?.toUpperCase() ?? t('book.unknownFormat') }) }}
+ {{ formatButtonTooltip(file) }}
diff --git a/client/src/features/book/components/BookQuickView.vue b/client/src/features/book/components/BookQuickView.vue
index babce96e8..bc7b19e81 100644
--- a/client/src/features/book/components/BookQuickView.vue
+++ b/client/src/features/book/components/BookQuickView.vue
@@ -13,7 +13,7 @@ import { useBookDetail } from '../composables/useBookDetail'
import { useCoverVersions } from '../composables/useCoverVersions'
import { getFormatColor } from '../lib/format-colors'
import { displayPublishedDate } from '../lib/published-date'
-import { FORMAT_TO_GROUP } from '@bookorbit/types'
+import { FORMAT_TO_GROUP, type BookFileRef } from '@bookorbit/types'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
@@ -21,6 +21,7 @@ import { useSafeHtml } from '@/features/book/composables/useSafeHtml'
import BookCoverArtwork from './BookCoverArtwork.vue'
import BookCoverSurface from './BookCoverSurface.vue'
import { useI18n } from 'vue-i18n'
+import { hasReadAlong, isReadAlongFormat, READ_ALONG_FORMAT_COLOR, READ_ALONG_FORMAT_TITLE } from '@/features/book/lib/file-capabilities'
const { t } = useI18n()
@@ -93,6 +94,7 @@ const quickViewCoverAspectRatio = computed(() => {
})
const primaryFile = computed(() => detail.value?.files.find((f) => f.role === 'primary') ?? detail.value?.files[0] ?? null)
+const readAlongFile = computed(() => detail.value?.files.find((file) => hasReadAlong(file)) ?? null)
const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const isPrimaryComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
const knownFormats = computed(() => [
@@ -109,7 +111,7 @@ function providerLinkStyle(provider: string) {
}
function formatBadgeStyle(fmt: string) {
- const color = getFormatColor(fmt)
+ const color = formatHasReadAlong(fmt) ? READ_ALONG_FORMAT_COLOR : getFormatColor(fmt)
return {
color,
borderColor: `${color}66`,
@@ -117,6 +119,19 @@ function formatBadgeStyle(fmt: string) {
}
}
+function fileFormatBadgeStyle(file: Pick) {
+ const color = hasReadAlong(file) ? READ_ALONG_FORMAT_COLOR : getFormatColor(file.format ?? '?')
+ return {
+ color,
+ borderColor: `${color}66`,
+ backgroundColor: `${color}1a`,
+ }
+}
+
+function formatHasReadAlong(fmt: string): boolean {
+ return isReadAlongFormat(fmt, readAlongFile.value != null)
+}
+
function handleCoverLoad(ratio: number | null) {
coverLoaded.value = true
coverFailed.value = false
@@ -289,10 +304,12 @@ function handleDelete() {
{{ fmt }}
+
{{ t('book.quickView.pages', { count: detail.pageCount }) }}
@@ -321,10 +338,12 @@ function handleDelete() {
{{ (primaryFile.format ?? '?').toUpperCase() }}
+
{{ formatBytes(primaryFile.sizeBytes) }}
diff --git a/client/src/features/book/components/BookSortBuilder.vue b/client/src/features/book/components/BookSortBuilder.vue
index 61a4ca74c..e96bcc5cf 100644
--- a/client/src/features/book/components/BookSortBuilder.vue
+++ b/client/src/features/book/components/BookSortBuilder.vue
@@ -29,10 +29,13 @@ const customSortFields = computed(() =>
.map((f) => customSortField(f.id)),
)
+// Relevance only ranks against a search query, so the browse sort builder never offers it.
+const browseSortFields = SORT_FIELDS.filter((field) => field !== 'relevance')
+
// Smart scopes and home shelves are not scoped to one collection, so the server rejects a
// membership sort from them and offering it would hand the user a sort that fails.
const offerableFields = computed(() =>
- props.collectionScoped ? SORT_FIELDS : SORT_FIELDS.filter((f) => !isCollectionScopedSortField(f)),
+ props.collectionScoped ? browseSortFields : browseSortFields.filter((f) => !isCollectionScopedSortField(f)),
)
const allFields = computed(() => [...offerableFields.value, ...customSortFields.value])
diff --git a/client/src/features/book/components/__tests__/BookCoverCard.spec.ts b/client/src/features/book/components/__tests__/BookCoverCard.spec.ts
index 4941a19bb..ef6dc27c8 100644
--- a/client/src/features/book/components/__tests__/BookCoverCard.spec.ts
+++ b/client/src/features/book/components/__tests__/BookCoverCard.spec.ts
@@ -197,7 +197,19 @@ describe('BookCoverCard — cover aspect override', () => {
})
})
-describe('BookCoverCard — present state', () => {
+describe('BookCoverCard - present state', () => {
+ it('marks the EPUB format overlay for media-overlay read-along files', () => {
+ cardOverlays.value = ['format']
+ const wrapper = mountCard({
+ ...presentBook,
+ files: [{ id: 10, format: 'epub', role: 'primary', sizeBytes: null, mediaOverlay: { available: true, durationSeconds: 42 } }],
+ })
+
+ expect(wrapper.text()).toContain('EPUB')
+ expect(wrapper.text()).not.toContain('NARR')
+ expect(wrapper.find('.lucide-headphones').exists()).toBe(true)
+ })
+
it('opens the reader on desktop card click by default', async () => {
const wrapper = mountCard(presentBook)
diff --git a/client/src/features/book/components/__tests__/BookListRow.spec.ts b/client/src/features/book/components/__tests__/BookListRow.spec.ts
index e1724c5ee..ab3de7fb1 100644
--- a/client/src/features/book/components/__tests__/BookListRow.spec.ts
+++ b/client/src/features/book/components/__tests__/BookListRow.spec.ts
@@ -221,6 +221,23 @@ describe('BookListRow - present state', () => {
expect(wrapper.find('[class*="bg-amber-500"]').exists()).toBe(false)
})
+ it('marks the EPUB format button for media-overlay read-along files', () => {
+ const wrapper = mount(BookListRow, {
+ props: {
+ book: {
+ ...presentBook,
+ files: [{ id: 10, format: 'epub', role: 'primary', sizeBytes: null, mediaOverlay: { available: true, durationSeconds: 42 } }],
+ },
+ },
+ global: globalStubs,
+ })
+
+ expect(wrapper.text()).toContain('epub')
+ expect(wrapper.text()).not.toContain('NARR')
+ expect(wrapper.text()).toContain('Open read-along EPUB')
+ expect(wrapper.find('.lucide-headphones').exists()).toBe(true)
+ })
+
it('applies hover:bg-muted/50 when present with a readable file', () => {
const wrapper = mount(BookListRow, { props: { book: presentBook }, global: globalStubs })
const root = wrapper.find('.flex.items-center')
diff --git a/client/src/features/book/components/__tests__/BookQuickView.spec.ts b/client/src/features/book/components/__tests__/BookQuickView.spec.ts
index c2aa25fd6..bf40f9836 100644
--- a/client/src/features/book/components/__tests__/BookQuickView.spec.ts
+++ b/client/src/features/book/components/__tests__/BookQuickView.spec.ts
@@ -108,6 +108,17 @@ function makeDetail(overrides: Partial = {}): BookDetail {
metadataScore: null,
readStatus: null,
audioMetadata: null,
+ readAloudSync: {
+ mode: 'auto',
+ state: 'unavailable',
+ unavailableReason: 'no_media_overlay_epub',
+ overlayFileId: null,
+ audioDurationSeconds: null,
+ overlayDurationSeconds: null,
+ durationDifferenceSeconds: null,
+ durationDifferenceRatio: null,
+ koreaderDownloadAvailable: false,
+ },
formatPriority: [],
comicMetadata: null,
customMetadata: [],
diff --git a/client/src/features/book/components/__tests__/BookTableCells.spec.ts b/client/src/features/book/components/__tests__/BookTableCells.spec.ts
index 963e635ba..da2aef602 100644
--- a/client/src/features/book/components/__tests__/BookTableCells.spec.ts
+++ b/client/src/features/book/components/__tests__/BookTableCells.spec.ts
@@ -159,6 +159,14 @@ describe('BookTableFormatCell', () => {
expect(wrapper.text()).toContain('epub')
})
+ it('marks the EPUB format badge for media-overlay read-along files', () => {
+ const wrapper = mountFormat([{ id: 1, format: 'epub', role: 'primary', sizeBytes: null, mediaOverlay: { available: true, durationSeconds: 42 } }])
+ expect(wrapper.text()).toContain('epub')
+ expect(wrapper.text()).not.toContain('NARR')
+ expect(wrapper.text()).toContain('Read-along EPUB')
+ expect(wrapper.find('.lucide-headphones').exists()).toBe(true)
+ })
+
it('shows multiple format badges for multiple files', () => {
const wrapper = mountFormat([
{ id: 1, format: 'epub', role: 'primary', sizeBytes: null },
diff --git a/client/src/features/book/components/__tests__/DetailsTab.spec.ts b/client/src/features/book/components/__tests__/DetailsTab.spec.ts
index 164266518..83e1b2d61 100644
--- a/client/src/features/book/components/__tests__/DetailsTab.spec.ts
+++ b/client/src/features/book/components/__tests__/DetailsTab.spec.ts
@@ -112,6 +112,17 @@ function makeBook(overrides: Partial = {}): BookDetail {
metadataScore: null,
readStatus: null,
audioMetadata: null,
+ readAloudSync: {
+ mode: 'auto',
+ state: 'unavailable',
+ unavailableReason: 'no_media_overlay_epub',
+ overlayFileId: null,
+ audioDurationSeconds: null,
+ overlayDurationSeconds: null,
+ durationDifferenceSeconds: null,
+ durationDifferenceRatio: null,
+ koreaderDownloadAvailable: false,
+ },
formatPriority: [],
comicMetadata: null,
customMetadata: [],
@@ -513,6 +524,55 @@ describe('DetailsTab - present state', () => {
expect(wrapper.text()).toContain('>99%')
})
+ it('shows EPUB3 narration progress when only media-overlay position is stored', async () => {
+ vi.mocked(api).mockImplementation(async (input) => {
+ if (input === '/api/v1/books/1/progress') {
+ return makeApiResponse([
+ {
+ fileId: 101,
+ cfi: null,
+ pageNumber: null,
+ percentage: 0,
+ positionSeconds: 900,
+ mediaOverlayFragment: 'OPS/chapter.xhtml#s12',
+ mediaOverlaySectionIndex: 3,
+ updatedAt: null,
+ },
+ ])
+ }
+ if (input === '/api/v1/collections?bookIds=1') {
+ return makeApiResponse([])
+ }
+ return makeApiResponse({}, false)
+ })
+
+ const wrapper = mount(DetailsTab, {
+ props: {
+ book: makeBook({
+ files: [
+ {
+ id: 101,
+ format: 'epub',
+ role: 'primary',
+ sizeBytes: 1234,
+ absolutePath: '/books/test.epub',
+ createdAt: '2024-01-01T00:00:00.000Z',
+ filename: 'test.epub',
+ durationSeconds: null,
+ mediaOverlay: { available: true, durationSeconds: 3600 },
+ },
+ ],
+ }),
+ },
+ global: globalStubs,
+ })
+
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('epub')
+ expect(wrapper.text()).toContain('25%')
+ })
+
it('resets a single file progress row from the inline control', async () => {
let progressRows: Array<{ fileId: number; cfi: string | null; pageNumber: number | null; percentage: number; updatedAt: string | null }> = [
{ fileId: 101, cfi: null, pageNumber: null, percentage: 22, updatedAt: null },
diff --git a/client/src/features/book/components/detail/reading-log/ReadingLogLedger.vue b/client/src/features/book/components/detail/reading-log/ReadingLogLedger.vue
index 182a7df75..9f85b0e84 100644
--- a/client/src/features/book/components/detail/reading-log/ReadingLogLedger.vue
+++ b/client/src/features/book/components/detail/reading-log/ReadingLogLedger.vue
@@ -38,6 +38,8 @@ const ROW_GRID =
const BUCKET_TOKEN: Record = {
bookorbit: '--pill-web',
+ ios: '--pill-ios',
+ watchos: '--pill-watchos',
koreader: '--pill-koreader',
kobo: '--pill-kobo',
}
diff --git a/client/src/features/book/components/detail/reading-log/ReadingLogSourceSplit.vue b/client/src/features/book/components/detail/reading-log/ReadingLogSourceSplit.vue
index b9c1b304a..0f858f460 100644
--- a/client/src/features/book/components/detail/reading-log/ReadingLogSourceSplit.vue
+++ b/client/src/features/book/components/detail/reading-log/ReadingLogSourceSplit.vue
@@ -18,6 +18,8 @@ const { t } = useI18n()
const BUCKET_TOKEN: Record = {
bookorbit: '--pill-web',
+ ios: '--pill-ios',
+ watchos: '--pill-watchos',
koreader: '--pill-koreader',
kobo: '--pill-kobo',
}
diff --git a/client/src/features/book/components/detail/reading-log/ReadingLogTraceChart.vue b/client/src/features/book/components/detail/reading-log/ReadingLogTraceChart.vue
index 6b5c6deec..7c770b6c6 100644
--- a/client/src/features/book/components/detail/reading-log/ReadingLogTraceChart.vue
+++ b/client/src/features/book/components/detail/reading-log/ReadingLogTraceChart.vue
@@ -24,6 +24,8 @@ onMounted(() => initChartThemes())
const DAY_MS = 24 * 60 * 60 * 1000
const BUCKET_TOKEN: Record = {
bookorbit: '--pill-web',
+ ios: '--pill-ios',
+ watchos: '--pill-watchos',
koreader: '--pill-koreader',
kobo: '--pill-kobo',
}
diff --git a/client/src/features/book/components/detail/reading-log/__tests__/ReadingLogSourceSplit.spec.ts b/client/src/features/book/components/detail/reading-log/__tests__/ReadingLogSourceSplit.spec.ts
index a8f540a7e..dcfbc26ba 100644
--- a/client/src/features/book/components/detail/reading-log/__tests__/ReadingLogSourceSplit.spec.ts
+++ b/client/src/features/book/components/detail/reading-log/__tests__/ReadingLogSourceSplit.spec.ts
@@ -53,6 +53,8 @@ describe('ReadingLogSourceSplit', () => {
props: {
stats: makeStats([
{ bucket: 'bookorbit', totalSeconds: 3600, totalSessions: 4 },
+ { bucket: 'ios', totalSeconds: 60, totalSessions: 1 },
+ { bucket: 'watchos', totalSeconds: 60, totalSessions: 1 },
{ bucket: 'koreader', totalSeconds: 120, totalSessions: 1 },
{ bucket: 'kobo', totalSeconds: 30, totalSessions: 1 },
]),
@@ -62,10 +64,12 @@ describe('ReadingLogSourceSplit', () => {
const text = wrapper.text()
expect(text).toContain('Reading sources')
expect(text).toContain('BookOrbit')
+ expect(text).toContain('iOS app')
+ expect(text).toContain('Apple Watch')
expect(text).toContain('KOReader')
expect(text).toContain('Kobo')
- // total = 3750s -> 96% / 3% / 1%
- expect(text).toContain('96%')
+ // total = 3870s, with visible independent native-client segments.
+ expect(text).toContain('93%')
expect(text).toContain('3%')
expect(text).toContain('1%')
expect(text).toContain('1h 0m')
diff --git a/client/src/features/book/components/detail/tabs/DetailsTab.vue b/client/src/features/book/components/detail/tabs/DetailsTab.vue
index 90e2b0572..5b629896a 100644
--- a/client/src/features/book/components/detail/tabs/DetailsTab.vue
+++ b/client/src/features/book/components/detail/tabs/DetailsTab.vue
@@ -67,11 +67,15 @@ import BookEditionsCard from '@/features/book/components/detail/details/BookEdit
import BookReadingActivityCard from '@/features/book/components/detail/details/BookReadingActivityCard.vue'
import { useBookReadingLog } from '@/features/book/composables/useBookReadingLog'
import { useProviderLinkSettings } from '@/features/book/composables/useProviderLinkSettings'
+import { hasReadAlong, isReadAlongFormat, READ_ALONG_FORMAT_COLOR, READ_ALONG_FORMAT_TITLE } from '@/features/book/lib/file-capabilities'
type FileProgress = {
percentage: number
cfi: string | null
pageNumber: number | null
+ positionSeconds: number | null
+ mediaOverlayFragment: string | null
+ mediaOverlaySectionIndex: number | null
updatedAt: string | null
}
@@ -345,6 +349,7 @@ const detailCoverAspectRatio = computed(() => {
return `${coverImageRatio.value} / 1`
})
const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null)
+const readAlongFile = computed(() => props.book.files.find((file) => hasReadAlong(file)) ?? null)
const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const isPrimaryComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
const readableFiles = computed(() => props.book.files.filter((f) => f.format && READER_OPENABLE_FORMATS.has(f.format)))
@@ -750,11 +755,38 @@ const fileProgressRows = computed(() =>
percentage: 0,
cfi: null,
pageNumber: null,
+ positionSeconds: null,
+ mediaOverlayFragment: null,
+ mediaOverlaySectionIndex: null,
updatedAt: null,
},
})),
)
-const detailProgressRows = computed(() => fileProgressRows.value.filter(({ progress }) => progress.percentage > 0))
+
+function hasMediaOverlayProgress(progress: FileProgress): boolean {
+ return (
+ (progress.positionSeconds != null && progress.positionSeconds > 0) || !!progress.mediaOverlayFragment || progress.mediaOverlaySectionIndex != null
+ )
+}
+
+function effectiveFileProgressPercentage(file: BookDetail['files'][number], progress: FileProgress): number {
+ if (progress.percentage > 0) return progress.percentage
+ const duration = file.mediaOverlay?.durationSeconds
+ if (duration != null && duration > 0 && progress.positionSeconds != null && progress.positionSeconds > 0) {
+ return (progress.positionSeconds / duration) * 100
+ }
+ return progress.percentage
+}
+
+const detailProgressRows = computed(() =>
+ fileProgressRows.value
+ .map(({ file, progress }) => ({
+ file,
+ progress,
+ percentage: effectiveFileProgressPercentage(file, progress),
+ }))
+ .filter(({ progress, percentage }) => percentage > 0 || hasMediaOverlayProgress(progress)),
+)
type ProgressRow = {
label: string
@@ -770,14 +802,14 @@ const KOBO_COLOR = '#f59e0b'
const leftColumnProgressRows = computed(() => {
const rows: ProgressRow[] = []
- for (const { file, progress } of detailProgressRows.value) {
+ for (const { file, percentage } of detailProgressRows.value) {
const color = getFormatColor(file.format ?? '?')
rows.push({
label: (file.format ?? '?').toUpperCase(),
- percentage: progress.percentage,
+ percentage,
color,
badgeStyle: { color, borderColor: `${color}66`, backgroundColor: `${color}1a` },
- finished: progress.percentage >= 100,
+ finished: percentage >= 100,
resetFileId: file.id,
})
}
@@ -944,7 +976,7 @@ function formatDate(iso: string): string {
}
function formatBadgeStyle(fmt: string) {
- const color = getFormatColor(fmt)
+ const color = formatHasReadAlong(fmt) ? READ_ALONG_FORMAT_COLOR : getFormatColor(fmt)
return {
color,
borderColor: `${color}66`,
@@ -952,6 +984,10 @@ function formatBadgeStyle(fmt: string) {
}
}
+function formatHasReadAlong(fmt: string): boolean {
+ return isReadAlongFormat(fmt, readAlongFile.value != null)
+}
+
function providerLinkStyle(provider: string) {
const color = getProviderColor(provider)
return {
@@ -1119,9 +1155,13 @@ async function loadSupplemental() {
for (const row of progressRows) {
if (!Number.isFinite(row.fileId)) continue
progressMap[row.fileId] = {
- percentage: row.percentage,
+ percentage: typeof row.percentage === 'number' && Number.isFinite(row.percentage) ? row.percentage : 0,
cfi: row.cfi,
pageNumber: row.pageNumber,
+ positionSeconds: typeof row.positionSeconds === 'number' && Number.isFinite(row.positionSeconds) ? row.positionSeconds : null,
+ mediaOverlayFragment: typeof row.mediaOverlayFragment === 'string' ? row.mediaOverlayFragment : null,
+ mediaOverlaySectionIndex:
+ typeof row.mediaOverlaySectionIndex === 'number' && Number.isFinite(row.mediaOverlaySectionIndex) ? row.mediaOverlaySectionIndex : null,
updatedAt: row.updatedAt,
}
}
@@ -1617,6 +1657,7 @@ watch(
:key="fmt"
class="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded border"
:style="formatBadgeStyle(fmt)"
+ :title="formatHasReadAlong(fmt) ? READ_ALONG_FORMAT_TITLE : undefined"
>
@@ -1625,6 +1666,7 @@ watch(
{{ t('book.detail.details.primaryFormat') }}
{{ fmt }}
+
diff --git a/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts
index b57268037..536668a6c 100644
--- a/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts
+++ b/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts
@@ -81,6 +81,17 @@ function makeBook(overrides: Partial
= {}): BookDetail {
metadataScore: null,
readStatus: null,
audioMetadata: null,
+ readAloudSync: {
+ mode: 'auto',
+ state: 'unavailable',
+ unavailableReason: 'no_media_overlay_epub',
+ overlayFileId: null,
+ audioDurationSeconds: null,
+ overlayDurationSeconds: null,
+ durationDifferenceSeconds: null,
+ durationDifferenceRatio: null,
+ koreaderDownloadAvailable: false,
+ },
formatPriority: [],
comicMetadata: null,
customMetadata: [],
diff --git a/client/src/features/book/components/detail/tabs/__tests__/DetailsTabActions.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/DetailsTabActions.spec.ts
index 78b9dd007..c97ec1649 100644
--- a/client/src/features/book/components/detail/tabs/__tests__/DetailsTabActions.spec.ts
+++ b/client/src/features/book/components/detail/tabs/__tests__/DetailsTabActions.spec.ts
@@ -80,6 +80,17 @@ function makeBook(overrides: Partial = {}): BookDetail {
metadataScore: null,
readStatus: null,
audioMetadata: null,
+ readAloudSync: {
+ mode: 'auto',
+ state: 'unavailable',
+ unavailableReason: 'no_media_overlay_epub',
+ overlayFileId: null,
+ audioDurationSeconds: null,
+ overlayDurationSeconds: null,
+ durationDifferenceSeconds: null,
+ durationDifferenceRatio: null,
+ koreaderDownloadAvailable: false,
+ },
formatPriority: [],
comicMetadata: null,
customMetadata: [],
diff --git a/client/src/features/book/components/detail/tabs/__tests__/ReadingLogTab.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/ReadingLogTab.spec.ts
index 712d2abda..49fb15ead 100644
--- a/client/src/features/book/components/detail/tabs/__tests__/ReadingLogTab.spec.ts
+++ b/client/src/features/book/components/detail/tabs/__tests__/ReadingLogTab.spec.ts
@@ -2,7 +2,7 @@ import { mount, flushPromises } from '@vue/test-utils'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createPinia } from 'pinia'
import { RouterLinkStub } from '@vue/test-utils'
-import type { BookReadingSession, BookReadingSessionStats, ReadingAttempt } from '@bookorbit/types'
+import type { BookDetail, BookReadingSession, BookReadingSessionStats, ReadingAttempt } from '@bookorbit/types'
const mocks = vi.hoisted(() => ({
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(),
@@ -31,7 +31,7 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
import ReadingLogTab from '../ReadingLogTab.vue'
import ResetReadingStateDialog from '@/features/book/components/ResetReadingStateDialog.vue'
-function makeBook(overrides = {}) {
+function makeBook(overrides: Partial = {}): BookDetail {
return {
id: 10,
libraryId: 1,
@@ -67,6 +67,17 @@ function makeBook(overrides = {}) {
metadataScore: null,
readStatus: null,
audioMetadata: null,
+ readAloudSync: {
+ mode: 'auto',
+ state: 'unavailable',
+ unavailableReason: 'no_media_overlay_epub',
+ overlayFileId: null,
+ audioDurationSeconds: null,
+ overlayDurationSeconds: null,
+ durationDifferenceSeconds: null,
+ durationDifferenceRatio: null,
+ koreaderDownloadAvailable: false,
+ },
formatPriority: [],
comicMetadata: null,
customMetadata: [],
@@ -76,6 +87,20 @@ function makeBook(overrides = {}) {
}
}
+function makeBookFile(overrides: Partial = {}): BookDetail['files'][number] {
+ return {
+ id: 1,
+ format: 'epub',
+ role: 'primary',
+ sizeBytes: 1,
+ absolutePath: '/book.epub',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ filename: 'book.epub',
+ durationSeconds: null,
+ ...overrides,
+ }
+}
+
function makeSession(overrides: Partial = {}): BookReadingSession {
return {
id: 1,
@@ -262,17 +287,15 @@ describe('ReadingLogTab', () => {
it('offers a format filter only when the book has more than one format', async () => {
mocks.api.mockImplementation(routeApi({ sessions: [makeSession()] }))
- const single = mountTab(
- makeBook({ files: [{ id: 1, format: 'epub', bookId: 10, filePath: '/a.epub', fileName: 'a.epub', fileSize: 1, lastModified: null }] }),
- )
+ const single = mountTab(makeBook({ files: [makeBookFile({ id: 1, format: 'epub', filename: 'a.epub', absolutePath: '/a.epub' })] }))
await flushPromises()
expect(single.find('select').exists()).toBe(false)
const multiple = mountTab(
makeBook({
files: [
- { id: 1, format: 'epub', bookId: 10, filePath: '/a.epub', fileName: 'a.epub', fileSize: 1, lastModified: null },
- { id: 2, format: 'pdf', bookId: 10, filePath: '/a.pdf', fileName: 'a.pdf', fileSize: 1, lastModified: null },
+ makeBookFile({ id: 1, format: 'epub', filename: 'a.epub', absolutePath: '/a.epub' }),
+ makeBookFile({ id: 2, format: 'pdf', filename: 'a.pdf', absolutePath: '/a.pdf' }),
],
}),
)
diff --git a/client/src/features/book/components/table/BookTableFormatCell.vue b/client/src/features/book/components/table/BookTableFormatCell.vue
index 89acc4873..ca58d0b3d 100644
--- a/client/src/features/book/components/table/BookTableFormatCell.vue
+++ b/client/src/features/book/components/table/BookTableFormatCell.vue
@@ -1,9 +1,11 @@
@@ -38,16 +57,21 @@ const visibleFormats = computed(() => formats.value.slice(0, 2))
{{ fmt }}
+
- +{{ formats.length - 2 }}
+ +{{ formats.length - visibleFormats.length }}
{{ file.format ?? t('book.table.format.unknown') }} ({{ file.role }})
+ {{ READ_ALONG_FORMAT_TITLE }}
-
diff --git a/client/src/features/book/composables/__tests__/useBookDownload.spec.ts b/client/src/features/book/composables/__tests__/useBookDownload.spec.ts
index e9c4c58a1..09de829c9 100644
--- a/client/src/features/book/composables/__tests__/useBookDownload.spec.ts
+++ b/client/src/features/book/composables/__tests__/useBookDownload.spec.ts
@@ -63,6 +63,17 @@ describe('useBookDownload', () => {
expect(isDownloading.value).toBe(false)
})
+ it('downloads an audioless EPUB through the shared download path', async () => {
+ const useBookDownload = await loadComposable()
+ const { downloadAudiolessEpub, isDownloading } = useBookDownload()
+
+ await downloadAudiolessEpub(42)
+
+ expect(downloadMock.downloadFromUrl).toHaveBeenCalledWith('/api/v1/books/files/42/audioless-epub/download', 'book.epub')
+ expect(toastMock.error).not.toHaveBeenCalled()
+ expect(isDownloading.value).toBe(false)
+ })
+
it('uses primary export scope by default', async () => {
const useBookDownload = await loadComposable()
const { exportBooks } = useBookDownload()
@@ -242,7 +253,7 @@ describe('useBookDownload progress reporting', () => {
await exportBooks([1, 2, 3], false)
- expect(toastMock.loading.mock.calls.map((call) => call[0])).toEqual(['Preparing 3 books for download...', 'Downloading 25% of 100 B'])
+ expect(toastMock.loading.mock.calls.map((call) => call[0])).toEqual(['Preparing 3 books for download...', 'Downloading 25% of 100 byte'])
})
it('pluralises the export label for a single book', async () => {
diff --git a/client/src/features/book/composables/__tests__/useMetadataEditor.spec.ts b/client/src/features/book/composables/__tests__/useMetadataEditor.spec.ts
index 832ea2081..f43bdc3a4 100644
--- a/client/src/features/book/composables/__tests__/useMetadataEditor.spec.ts
+++ b/client/src/features/book/composables/__tests__/useMetadataEditor.spec.ts
@@ -55,6 +55,17 @@ function makeBook(overrides: Partial
= {}): BookDetail {
metadataScore: null,
readStatus: null,
audioMetadata: null,
+ readAloudSync: {
+ mode: 'auto',
+ state: 'unavailable',
+ unavailableReason: 'no_media_overlay_epub',
+ overlayFileId: null,
+ audioDurationSeconds: null,
+ overlayDurationSeconds: null,
+ durationDifferenceSeconds: null,
+ durationDifferenceRatio: null,
+ koreaderDownloadAvailable: false,
+ },
formatPriority: [],
comicMetadata: null,
customMetadata: [],
diff --git a/client/src/features/book/composables/__tests__/useMetadataLocks.spec.ts b/client/src/features/book/composables/__tests__/useMetadataLocks.spec.ts
index f9f843ec1..9b9c18f42 100644
--- a/client/src/features/book/composables/__tests__/useMetadataLocks.spec.ts
+++ b/client/src/features/book/composables/__tests__/useMetadataLocks.spec.ts
@@ -45,6 +45,17 @@ function makeBook(overrides: Partial = {}): BookDetail {
metadataScore: null,
readStatus: null,
audioMetadata: null,
+ readAloudSync: {
+ mode: 'auto',
+ state: 'unavailable',
+ unavailableReason: 'no_media_overlay_epub',
+ overlayFileId: null,
+ audioDurationSeconds: null,
+ overlayDurationSeconds: null,
+ durationDifferenceSeconds: null,
+ durationDifferenceRatio: null,
+ koreaderDownloadAvailable: false,
+ },
formatPriority: [],
comicMetadata: null,
customMetadata: [],
diff --git a/client/src/features/book/composables/__tests__/usePersonalNote.spec.ts b/client/src/features/book/composables/__tests__/usePersonalNote.spec.ts
index 9d04734d4..578beae66 100644
--- a/client/src/features/book/composables/__tests__/usePersonalNote.spec.ts
+++ b/client/src/features/book/composables/__tests__/usePersonalNote.spec.ts
@@ -46,6 +46,17 @@ function makeBook(overrides: Partial = {}): BookDetail {
metadataScore: null,
readStatus: null,
audioMetadata: null,
+ readAloudSync: {
+ mode: 'auto',
+ state: 'unavailable',
+ unavailableReason: 'no_media_overlay_epub',
+ overlayFileId: null,
+ audioDurationSeconds: null,
+ overlayDurationSeconds: null,
+ durationDifferenceSeconds: null,
+ durationDifferenceRatio: null,
+ koreaderDownloadAvailable: false,
+ },
formatPriority: [],
comicMetadata: null,
customMetadata: [],
diff --git a/client/src/features/book/composables/useBookDownload.ts b/client/src/features/book/composables/useBookDownload.ts
index b518ba670..9ab4a4030 100644
--- a/client/src/features/book/composables/useBookDownload.ts
+++ b/client/src/features/book/composables/useBookDownload.ts
@@ -73,6 +73,17 @@ export function useBookDownload() {
}
}
+ async function downloadAudiolessEpub(fileId: number): Promise {
+ isDownloading.value = true
+ try {
+ await downloadFromUrl(`/api/v1/books/files/${fileId}/audioless-epub/download`, 'book.epub')
+ } catch {
+ toast.error(t('book.download.failed'))
+ } finally {
+ isDownloading.value = false
+ }
+ }
+
async function exportBooks(bookIds: number[], allFormats: boolean, scopeOverride?: ExportScope): Promise {
if (bookIds.length === 0) return
const progress = createDownloadToast(t('book.download.preparingExport', { count: bookIds.length }), true)
@@ -92,5 +103,5 @@ export function useBookDownload() {
}
}
- return { isDownloading, downloadFile, exportBooks }
+ return { isDownloading, downloadFile, downloadAudiolessEpub, exportBooks }
}
diff --git a/client/src/features/book/lib/__tests__/metadata-refresh-feedback.spec.ts b/client/src/features/book/lib/__tests__/metadata-refresh-feedback.spec.ts
index a035147d3..1a3e21ed5 100644
--- a/client/src/features/book/lib/__tests__/metadata-refresh-feedback.spec.ts
+++ b/client/src/features/book/lib/__tests__/metadata-refresh-feedback.spec.ts
@@ -38,6 +38,17 @@ function makeBook(overrides: Partial = {}): BookDetail {
metadataScore: null,
readStatus: null,
audioMetadata: null,
+ readAloudSync: {
+ mode: 'auto',
+ state: 'unavailable',
+ unavailableReason: 'no_media_overlay_epub',
+ overlayFileId: null,
+ audioDurationSeconds: null,
+ overlayDurationSeconds: null,
+ durationDifferenceSeconds: null,
+ durationDifferenceRatio: null,
+ koreaderDownloadAvailable: false,
+ },
formatPriority: [],
comicMetadata: null,
customMetadata: [],
diff --git a/client/src/features/book/lib/book-card-mapper.test.ts b/client/src/features/book/lib/book-card-mapper.test.ts
index ab409f60c..8de168d81 100644
--- a/client/src/features/book/lib/book-card-mapper.test.ts
+++ b/client/src/features/book/lib/book-card-mapper.test.ts
@@ -158,6 +158,35 @@ describe('mergeBookCardWithDetail', () => {
expect(result.files[0]).not.toHaveProperty('filename')
})
+ it('preserves mediaOverlay capability when mapping files', () => {
+ const book = makeBook()
+ const detail = makeDetail({
+ files: [
+ {
+ id: 10,
+ format: 'epub',
+ role: 'primary',
+ sizeBytes: 1024,
+ absolutePath: '/some/path.epub',
+ createdAt: '2024-01-01T00:00:00.000Z',
+ filename: 'book.epub',
+ durationSeconds: null,
+ mediaOverlay: { available: true, durationSeconds: 42 },
+ },
+ ],
+ })
+
+ const result = mergeBookCardWithDetail(book, detail)
+
+ expect(result.files[0]).toEqual({
+ id: 10,
+ format: 'epub',
+ role: 'primary',
+ sizeBytes: 1024,
+ mediaOverlay: { available: true, durationSeconds: 42 },
+ })
+ })
+
it('sets hasCover = true when coverSource is non-null', () => {
const result = mergeBookCardWithDetail(makeBook(), makeDetail({ coverSource: 'extracted' }))
expect(result.hasCover).toBe(true)
diff --git a/client/src/features/book/lib/book-card-mapper.ts b/client/src/features/book/lib/book-card-mapper.ts
index 23be04759..c67460d45 100644
--- a/client/src/features/book/lib/book-card-mapper.ts
+++ b/client/src/features/book/lib/book-card-mapper.ts
@@ -7,6 +7,7 @@ function toBookFileRef(file: BookDetail['files'][number]): BookFileRef {
format: file.format,
role: file.role,
sizeBytes: file.sizeBytes,
+ ...(file.mediaOverlay ? { mediaOverlay: file.mediaOverlay } : {}),
}
}
@@ -50,7 +51,14 @@ function sameFiles(a: BookFileRef[], b: BookFileRef[]) {
return a.every((file, index) => {
const other = b[index]
if (!other) return false
- return file.id === other.id && file.format === other.format && file.role === other.role && file.sizeBytes === other.sizeBytes
+ return (
+ file.id === other.id &&
+ file.format === other.format &&
+ file.role === other.role &&
+ file.sizeBytes === other.sizeBytes &&
+ file.mediaOverlay?.available === other.mediaOverlay?.available &&
+ file.mediaOverlay?.durationSeconds === other.mediaOverlay?.durationSeconds
+ )
})
}
diff --git a/client/src/features/book/lib/file-capabilities.ts b/client/src/features/book/lib/file-capabilities.ts
new file mode 100644
index 000000000..43433cd43
--- /dev/null
+++ b/client/src/features/book/lib/file-capabilities.ts
@@ -0,0 +1,18 @@
+import type { BookFileRef } from '@bookorbit/types'
+
+export const READ_ALONG_FORMAT_TITLE = 'Read-along EPUB'
+export const READ_ALONG_FORMAT_COLOR = '#0f766e'
+
+export type ReadAlongFile = Pick | null | undefined
+
+export function hasReadAlong(file: ReadAlongFile): boolean {
+ return file?.format?.toLowerCase() === 'epub' && file.mediaOverlay?.available === true
+}
+
+export function hasAnyReadAlong(files: readonly ReadAlongFile[] | null | undefined): boolean {
+ return files?.some((file) => hasReadAlong(file)) ?? false
+}
+
+export function isReadAlongFormat(format: string | null | undefined, available: boolean): boolean {
+ return available && format?.toLowerCase() === 'epub'
+}
diff --git a/client/src/features/collection/components/AddToCollectionSheet.vue b/client/src/features/collection/components/AddToCollectionSheet.vue
index 10f51819a..1497c9f23 100644
--- a/client/src/features/collection/components/AddToCollectionSheet.vue
+++ b/client/src/features/collection/components/AddToCollectionSheet.vue
@@ -2,10 +2,11 @@
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
-import { FolderMinus, Library, Loader2, Plus } from '@lucide/vue'
+import { Library, LoaderCircle, Plus } from '@lucide/vue'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
+import CollectionMembershipList from './CollectionMembershipList.vue'
import { useCollections } from '../composables/useCollections'
import IconPicker from '@/components/IconPicker.vue'
import type { BookSelectionPayload, Collection } from '@bookorbit/types'
@@ -207,7 +208,7 @@ function handleFocusOut(e: FocusEvent) {
-
+
{{ t('collection.addToSheet.create') }}
@@ -217,32 +218,15 @@ function handleFocusOut(e: FocusEvent) {
{{ t('collection.addToSheet.collections') }}
-
-
-
- {{ collection.name }}
-
- {{ membershipLabel(collection) }}
-
-
-
-
-
-
-
+
diff --git a/client/src/features/collection/components/CollectionMembershipList.vue b/client/src/features/collection/components/CollectionMembershipList.vue
new file mode 100644
index 000000000..bcd3dd932
--- /dev/null
+++ b/client/src/features/collection/components/CollectionMembershipList.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+ {{ collection.name }}
+
+ {{ label(collection) }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/features/collection/components/CreateCollectionDialog.vue b/client/src/features/collection/components/CreateCollectionDialog.vue
index 74d6c8e13..af2a5f841 100644
--- a/client/src/features/collection/components/CreateCollectionDialog.vue
+++ b/client/src/features/collection/components/CreateCollectionDialog.vue
@@ -3,11 +3,12 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { X } from '@lucide/vue'
+import type { MediaType } from '@bookorbit/types'
import { useCollections } from '../composables/useCollections'
import IconPicker from '@/components/IconPicker.vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
-defineProps<{ open: boolean }>()
+const props = withDefaults(defineProps<{ open: boolean; mediaType?: MediaType }>(), { mediaType: 'books' })
const emit = defineEmits<{ close: [] }>()
const router = useRouter()
@@ -21,6 +22,8 @@ const saving = ref(false)
const error = ref
(null)
const trimmedName = computed(() => name.value.trim())
const trimmedIcon = computed(() => icon.value.trim())
+const isPodcastCollection = computed(() => props.mediaType === 'podcasts')
+const dialogTitle = computed(() => (isPodcastCollection.value ? t('collection.createDialog.podcastTitle') : t('collection.createDialog.title')))
async function submit() {
if (!trimmedName.value) {
@@ -34,12 +37,14 @@ async function submit() {
saving.value = true
error.value = null
try {
- const collection = await createCollection(trimmedName.value, trimmedIcon.value, undefined, isPublic.value)
+ const collection = isPublic.value
+ ? await createCollection(trimmedName.value, trimmedIcon.value, undefined, true, props.mediaType)
+ : await createCollection(trimmedName.value, trimmedIcon.value, undefined, props.mediaType)
name.value = ''
icon.value = ''
isPublic.value = false
emit('close')
- router.push({ name: 'collection', params: { id: collection.id } })
+ router.push({ name: isPodcastCollection.value ? 'podcast-collection' : 'collection', params: { id: collection.id } })
} catch {
error.value = t('collection.dialog.createFailed')
} finally {
@@ -54,7 +59,7 @@ async function submit() {
-
{{ t('collection.createDialog.title') }}
+
{{ dialogTitle }}
diff --git a/client/src/features/collection/components/__tests__/AddToCollectionSheet.spec.ts b/client/src/features/collection/components/__tests__/AddToCollectionSheet.spec.ts
index 5260553a9..e4fd8eaa4 100644
--- a/client/src/features/collection/components/__tests__/AddToCollectionSheet.spec.ts
+++ b/client/src/features/collection/components/__tests__/AddToCollectionSheet.spec.ts
@@ -45,6 +45,7 @@ function makeCollection(overrides: Partial
= {}): Collection {
return {
id: 10,
userId: 1,
+ mediaType: 'books',
name: 'Favorites',
icon: 'FolderOpen',
description: null,
@@ -53,6 +54,7 @@ function makeCollection(overrides: Partial = {}): Collection {
syncToKobo: false,
displayOrder: 0,
bookCount: 3,
+ podcastCount: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
diff --git a/client/src/features/collection/components/__tests__/CreateCollectionDialog.spec.ts b/client/src/features/collection/components/__tests__/CreateCollectionDialog.spec.ts
new file mode 100644
index 000000000..690fcb5b1
--- /dev/null
+++ b/client/src/features/collection/components/__tests__/CreateCollectionDialog.spec.ts
@@ -0,0 +1,113 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent, h } from 'vue'
+import type { Collection } from '@bookorbit/types'
+import CreateCollectionDialog from '../CreateCollectionDialog.vue'
+
+const mockState = vi.hoisted(() => ({
+ createCollection: vi.fn<(name: string, icon: string, description?: string, mediaType?: string) => Promise>(),
+ push: vi.fn<() => void>(),
+}))
+
+vi.mock('../../composables/useCollections', () => ({
+ useCollections: () => ({ createCollection: mockState.createCollection }),
+}))
+
+vi.mock('vue-router', () => ({
+ useRouter: () => ({ push: mockState.push }),
+}))
+
+function makeCollection(overrides: Partial = {}): Collection {
+ return {
+ id: 4,
+ userId: 7,
+ mediaType: 'books',
+ name: 'Favorites',
+ icon: 'FolderOpen',
+ description: null,
+ isPublic: false,
+ isOwner: true,
+ syncToKobo: false,
+ displayOrder: 0,
+ bookCount: 0,
+ podcastCount: 0,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ ...overrides,
+ }
+}
+
+const IconPickerStub = defineComponent({
+ props: { modelValue: { type: String, default: '' } },
+ emits: ['update:modelValue'],
+ setup(props, { emit }) {
+ return () =>
+ h('input', {
+ class: 'icon-picker',
+ value: props.modelValue,
+ onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
+ })
+ },
+})
+
+function mountDialog(props: Record = {}) {
+ return mount(CreateCollectionDialog, {
+ props: { open: true, ...props },
+ global: { stubs: { Teleport: true, IconPicker: IconPickerStub } },
+ })
+}
+
+async function fillRequiredFields(wrapper: ReturnType, name: string): Promise {
+ await wrapper.find('input[type="text"]').setValue(name)
+ await wrapper.find('.icon-picker').setValue('FolderOpen')
+}
+
+describe('CreateCollectionDialog', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockState.createCollection.mockResolvedValue(makeCollection())
+ })
+
+ it('creates a book collection by default and routes to the book view', async () => {
+ const wrapper = mountDialog()
+
+ await fillRequiredFields(wrapper, 'Favorites')
+ await wrapper.find('form').trigger('submit')
+ await flushPromises()
+
+ expect(mockState.createCollection).toHaveBeenCalledWith('Favorites', 'FolderOpen', undefined, 'books')
+ expect(mockState.push).toHaveBeenCalledWith({ name: 'collection', params: { id: 4 } })
+ })
+
+ it('creates a podcast collection when the dialog is opened for podcasts', async () => {
+ mockState.createCollection.mockResolvedValue(makeCollection({ id: 9, mediaType: 'podcasts' }))
+ const wrapper = mountDialog({ mediaType: 'podcasts' })
+
+ await fillRequiredFields(wrapper, 'Sci-fi podcasts')
+ await wrapper.find('form').trigger('submit')
+ await flushPromises()
+
+ expect(mockState.createCollection).toHaveBeenCalledWith('Sci-fi podcasts', 'FolderOpen', undefined, 'podcasts')
+ })
+
+ it('routes a new podcast collection to the podcast view rather than the book one', async () => {
+ mockState.createCollection.mockResolvedValue(makeCollection({ id: 9, mediaType: 'podcasts' }))
+ const wrapper = mountDialog({ mediaType: 'podcasts' })
+
+ await fillRequiredFields(wrapper, 'Sci-fi podcasts')
+ await wrapper.find('form').trigger('submit')
+ await flushPromises()
+
+ expect(mockState.push).toHaveBeenCalledWith({ name: 'podcast-collection', params: { id: 9 } })
+ })
+
+ it('does not submit without a name', async () => {
+ const wrapper = mountDialog()
+
+ await wrapper.find('.icon-picker').setValue('FolderOpen')
+ await wrapper.find('form').trigger('submit')
+ await flushPromises()
+
+ expect(mockState.createCollection).not.toHaveBeenCalled()
+ })
+})
diff --git a/client/src/features/collection/composables/__tests__/useCollections.spec.ts b/client/src/features/collection/composables/__tests__/useCollections.spec.ts
index 8beafd859..55c2f705e 100644
--- a/client/src/features/collection/composables/__tests__/useCollections.spec.ts
+++ b/client/src/features/collection/composables/__tests__/useCollections.spec.ts
@@ -11,6 +11,7 @@ function makeCollection(overrides: Partial = {}): Collection {
return {
id: 7,
userId: 1,
+ mediaType: 'books',
name: 'Favorites',
icon: 'FolderOpen',
description: null,
@@ -19,6 +20,7 @@ function makeCollection(overrides: Partial = {}): Collection {
syncToKobo: false,
displayOrder: 0,
bookCount: 0,
+ podcastCount: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
@@ -157,4 +159,61 @@ describe('useCollections', () => {
expect(collections.value).toEqual([second])
expect(loaded.value).toBe(true)
})
+
+ it('splits collections by medium so each surface renders only its own', async () => {
+ const { useCollections } = await import('../useCollections')
+ apiMock.mockResolvedValueOnce(makeResponse([makeCollection({ id: 1, mediaType: 'books' }), makeCollection({ id: 2, mediaType: 'podcasts' })]))
+ const { bookCollections, podcastCollections, fetchCollections } = useCollections()
+
+ await fetchCollections()
+
+ expect(bookCollections.value.map((collection) => collection.id)).toEqual([1])
+ expect(podcastCollections.value.map((collection) => collection.id)).toEqual([2])
+ })
+
+ it('coalesces concurrent requests for the same podcast collection page', async () => {
+ let resolveRequest!: (response: Response) => void
+ apiMock.mockReturnValueOnce(new Promise((resolve) => (resolveRequest = resolve)))
+ const { useCollections } = await import('../useCollections')
+ const { fetchCollectionPodcasts } = useCollections()
+
+ const first = fetchCollectionPodcasts(5, 1, 50)
+ const second = fetchCollectionPodcasts(5, 1, 50)
+
+ expect(apiMock).toHaveBeenCalledTimes(1)
+ resolveRequest(makeResponse({ items: [], total: 0, page: 1, size: 50 }))
+ await Promise.all([first, second])
+ })
+
+ it('sends the media type when creating a podcast collection', async () => {
+ const { useCollections } = await import('../useCollections')
+ apiMock.mockResolvedValueOnce(makeResponse(makeCollection({ mediaType: 'podcasts' })))
+ const { createCollection } = useCollections()
+
+ await createCollection('Sci-fi', 'Podcast', undefined, 'podcasts')
+
+ expect(apiMock).toHaveBeenCalledWith(
+ '/api/v1/collections',
+ expect.objectContaining({ body: JSON.stringify({ name: 'Sci-fi', icon: 'Podcast', mediaType: 'podcasts' }) }),
+ )
+ })
+
+ it('adds and removes shows through the podcasts membership endpoint', async () => {
+ const { useCollections } = await import('../useCollections')
+ const { addPodcastsToCollection, removePodcastsFromCollection } = useCollections()
+
+ apiMock.mockResolvedValueOnce(makeResponse(null))
+ await addPodcastsToCollection(5, [7, 8])
+ expect(apiMock).toHaveBeenCalledWith(
+ '/api/v1/collections/5/podcasts',
+ expect.objectContaining({ method: 'POST', body: JSON.stringify({ podcastIds: [7, 8] }) }),
+ )
+
+ apiMock.mockResolvedValueOnce(makeResponse(null))
+ await removePodcastsFromCollection(5, [7])
+ expect(apiMock).toHaveBeenCalledWith(
+ '/api/v1/collections/5/podcasts',
+ expect.objectContaining({ method: 'DELETE', body: JSON.stringify({ podcastIds: [7] }) }),
+ )
+ })
})
diff --git a/client/src/features/collection/composables/useCollections.ts b/client/src/features/collection/composables/useCollections.ts
index e73535e19..4c35ec16d 100644
--- a/client/src/features/collection/composables/useCollections.ts
+++ b/client/src/features/collection/composables/useCollections.ts
@@ -1,51 +1,65 @@
-import { ref } from 'vue'
+import { computed, ref } from 'vue'
import { api } from '@/lib/api'
-import type { BookSelectionPayload, Collection, CreateCollectionPayload } from '@bookorbit/types'
+import { createCoalescedFetch, createRequestGeneration } from '@/lib/async'
+import type { BookSelectionPayload, Collection, CreateCollectionPayload, MediaType, PodcastListItem, PodcastPage } from '@bookorbit/types'
const collections = ref([])
const loaded = ref(false)
const loading = ref(false)
const error = ref(null)
-let fetchPromise: Promise | null = null
-let requestGeneration = 0
+const listGeneration = createRequestGeneration()
+
+const loadCollectionList = createCoalescedFetch(async (): Promise => {
+ loading.value = true
+ error.value = null
+ const generation = listGeneration.current()
+ try {
+ const res = await api('/api/v1/collections')
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ const nextCollections: Collection[] = await res.json()
+ if (!listGeneration.isCurrent(generation)) return
+ collections.value = nextCollections
+ loaded.value = true
+ } catch (e: unknown) {
+ if (!listGeneration.isCurrent(generation)) return
+ error.value = e instanceof Error ? e.message : 'Failed to load collections'
+ } finally {
+ if (listGeneration.isCurrent(generation)) loading.value = false
+ }
+})
+
+const fetchCollectionPodcastPage = createCoalescedFetch(
+ async (path: string): Promise> => {
+ const res = await api(path)
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ return res.json() as Promise>
+ },
+ (path) => path,
+)
export function resetCollections(): void {
- requestGeneration += 1
+ listGeneration.invalidate()
collections.value = []
loaded.value = false
loading.value = false
error.value = null
- fetchPromise = null
+ loadCollectionList.clear()
+ fetchCollectionPodcastPage.clear()
}
+/** The list endpoint returns every medium; each surface picks the one it renders. */
+const bookCollections = computed(() => collections.value.filter((collection) => collection.mediaType !== 'podcasts'))
+const podcastCollections = computed(() => collections.value.filter((collection) => collection.mediaType === 'podcasts'))
+
export function useCollections() {
async function fetchCollections(): Promise {
if (loaded.value) return
- if (fetchPromise) return fetchPromise
- loading.value = true
- error.value = null
- const generation = requestGeneration
- fetchPromise = api('/api/v1/collections')
- .then(async (res) => {
- if (!res.ok) {
- throw new Error(`HTTP ${res.status}`)
- }
- const nextCollections: Collection[] = await res.json()
- if (generation !== requestGeneration) return
- collections.value = nextCollections
- loaded.value = true
- })
- .catch((e: unknown) => {
- if (generation !== requestGeneration) return
- error.value = e instanceof Error ? e.message : 'Failed to load collections'
- })
- .finally(() => {
- if (generation === requestGeneration) {
- loading.value = false
- fetchPromise = null
- }
- })
- return fetchPromise
+ return loadCollectionList()
+ }
+
+ /** Forces a re-fetch after any in-flight list request finishes. */
+ async function refreshCollections(): Promise {
+ return loadCollectionList()
}
async function fetchCollectionsWithMembership(selectionPayload: BookSelectionPayload): Promise {
@@ -58,8 +72,22 @@ export function useCollections() {
return res.json()
}
- async function createCollection(name: string, icon: string, description?: string, isPublic?: boolean): Promise {
- const payload: CreateCollectionPayload = { name, icon, description, ...(isPublic !== undefined && { isPublic }) }
+ async function createCollection(
+ name: string,
+ icon: string,
+ description?: string,
+ isPublicOrMediaType?: boolean | MediaType,
+ explicitMediaType?: MediaType,
+ ): Promise {
+ const isPublic = typeof isPublicOrMediaType === 'boolean' ? isPublicOrMediaType : undefined
+ const mediaType = typeof isPublicOrMediaType === 'string' ? isPublicOrMediaType : explicitMediaType
+ const payload: CreateCollectionPayload = {
+ name,
+ icon,
+ description,
+ ...(isPublic !== undefined && { isPublic }),
+ ...(mediaType !== undefined && { mediaType }),
+ }
const res = await api('/api/v1/collections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -129,12 +157,59 @@ export function useCollections() {
collections.value = collections.value.filter((collection) => collection.id !== id)
}
+ async function fetchCollectionPodcasts(id: number, page = 0, size = 50): Promise> {
+ const params = new URLSearchParams({ page: String(page), size: String(size) })
+ return fetchCollectionPodcastPage(`/api/v1/collections/${id}/podcasts?${params.toString()}`)
+ }
+
+ /**
+ * A collection's stored count is a snapshot; paging its shows is the freshest count anyone has.
+ * Reconciling here keeps the sidebar badge honest without a view reaching into this cache.
+ */
+ function applyCollectionPodcastCount(id: number, podcastCount: number): void {
+ const collection = collections.value.find((candidate) => candidate.id === id)
+ if (collection) collection.podcastCount = Math.max(0, podcastCount)
+ }
+
+ /** One request instead of one per collection, so opening the sheet stays cheap at scale. */
+ async function fetchPodcastCollectionMembership(podcastId: number): Promise {
+ const res = await api(`/api/v1/collections/podcast-membership?podcastId=${podcastId}`)
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ return res.json()
+ }
+
+ async function addPodcastsToCollection(id: number, podcastIds: number[]): Promise {
+ const res = await api(`/api/v1/collections/${id}/podcasts`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ podcastIds }),
+ })
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ }
+
+ async function removePodcastsFromCollection(id: number, podcastIds: number[]): Promise {
+ const res = await api(`/api/v1/collections/${id}/podcasts`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ podcastIds }),
+ })
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ }
+
return {
collections,
+ bookCollections,
+ podcastCollections,
+ fetchCollectionPodcasts,
+ applyCollectionPodcastCount,
+ fetchPodcastCollectionMembership,
+ addPodcastsToCollection,
+ removePodcastsFromCollection,
loaded,
loading,
error,
fetchCollections,
+ refreshCollections,
fetchCollectionsWithMembership,
createCollection,
updateCollection,
diff --git a/client/src/features/collection/lib/collection-access.spec.ts b/client/src/features/collection/lib/collection-access.spec.ts
index 915546058..b78f0831a 100644
--- a/client/src/features/collection/lib/collection-access.spec.ts
+++ b/client/src/features/collection/lib/collection-access.spec.ts
@@ -1,11 +1,12 @@
import { describe, expect, it } from 'vitest'
import type { Collection } from '@bookorbit/types'
-import { canMutateCollection, ownedCollectionOrder } from './collection-access'
+import { canMutateCollection } from './collection-access'
function makeCollection(id: number, isOwner: boolean): Collection {
return {
id,
userId: isOwner ? 7 : 99,
+ mediaType: 'books',
name: `Collection ${id}`,
icon: 'FolderOpen',
description: null,
@@ -14,6 +15,7 @@ function makeCollection(id: number, isOwner: boolean): Collection {
syncToKobo: false,
displayOrder: id,
bookCount: 0,
+ podcastCount: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
@@ -25,19 +27,4 @@ describe('collection access policy', () => {
expect(canMutateCollection(makeCollection(2, false))).toBe(false)
expect(canMutateCollection(undefined)).toBe(false)
})
-
- it('removes shared collection IDs before persisting sidebar order', () => {
- const collections = [makeCollection(1, true), makeCollection(2, false), makeCollection(3, true)]
-
- expect(
- ownedCollectionOrder(collections, [
- { id: 2, displayOrder: 0 },
- { id: 3, displayOrder: 1 },
- { id: 1, displayOrder: 2 },
- ]),
- ).toEqual([
- { id: 3, displayOrder: 1 },
- { id: 1, displayOrder: 2 },
- ])
- })
})
diff --git a/client/src/features/collection/lib/collection-access.ts b/client/src/features/collection/lib/collection-access.ts
index 2db8311eb..05c41f5c4 100644
--- a/client/src/features/collection/lib/collection-access.ts
+++ b/client/src/features/collection/lib/collection-access.ts
@@ -3,11 +3,3 @@ import type { Collection } from '@bookorbit/types'
export function canMutateCollection(collection: Collection | null | undefined): boolean {
return collection?.isOwner === true
}
-
-export function ownedCollectionOrder(
- collections: readonly Collection[],
- order: readonly { id: number; displayOrder: number }[],
-): { id: number; displayOrder: number }[] {
- const ownedIds = new Set(collections.filter(canMutateCollection).map((collection) => collection.id))
- return order.filter((entry) => ownedIds.has(entry.id))
-}
diff --git a/client/src/features/collection/views/CollectionsView.vue b/client/src/features/collection/views/CollectionsView.vue
index ba87f199c..99b8bab30 100644
--- a/client/src/features/collection/views/CollectionsView.vue
+++ b/client/src/features/collection/views/CollectionsView.vue
@@ -8,7 +8,7 @@ import { useCollections } from '@/features/collection/composables/useCollections
defineOptions({ name: 'CollectionsView' })
const { t } = useI18n()
-const { collections, loading, fetchCollections } = useCollections()
+const { bookCollections, loading, fetchCollections } = useCollections()
const createOpen = ref(false)
@@ -32,7 +32,7 @@ onMounted(() => {
:title="t('titles.collections')"
title-icon="FolderOpen"
fallback-icon="FolderOpen"
- :items="collections"
+ :items="bookCollections"
route-name="collection"
:loading="loading"
:search-placeholder="t('components.sidebar.filterCollectionsPlaceholder')"
diff --git a/client/src/features/dashboard/components/DashboardPodcastScroller.spec.ts b/client/src/features/dashboard/components/DashboardPodcastScroller.spec.ts
new file mode 100644
index 000000000..856ecda14
--- /dev/null
+++ b/client/src/features/dashboard/components/DashboardPodcastScroller.spec.ts
@@ -0,0 +1,151 @@
+import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
+import { nextTick, type Ref } from 'vue'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastEpisodeListItem } from '@bookorbit/types'
+import { api } from '@/lib/api'
+import DashboardPodcastScroller from './DashboardPodcastScroller.vue'
+
+const playerState = vi.hoisted(() => ({
+ episode: undefined as Ref<{ id: number; finished: boolean } | null> | undefined,
+ playInline: vi.fn<(episodeId: number) => Promise>(),
+ togglePlayback: vi.fn<() => void>(),
+}))
+
+vi.mock('@/lib/api', () => ({ api: vi.fn<(url: string) => Promise>() }))
+vi.mock('@/features/podcast/composables/usePodcastPlayer', async () => {
+ const { ref: createRef } = await import('vue')
+ playerState.episode = createRef(null)
+ return {
+ usePodcastPlayer: () => ({
+ episode: playerState.episode,
+ playInline: playerState.playInline,
+ togglePlayback: playerState.togglePlayback,
+ }),
+ }
+})
+
+const apiMock = vi.mocked(api)
+const wrappers: VueWrapper[] = []
+
+describe('DashboardPodcastScroller', () => {
+ beforeEach(() => {
+ apiMock.mockReset()
+ playerState.episode!.value = null
+ playerState.playInline.mockReset()
+ playerState.togglePlayback.mockReset()
+ })
+
+ afterEach(() => {
+ for (const wrapper of wrappers.splice(0)) wrapper.unmount()
+ })
+
+ it('loads the cross-library resume feed and renders each episode', async () => {
+ apiMock.mockImplementation(() => Promise.resolve(jsonResponse([episode(1), episode(2)])))
+ const wrapper = mountScroller()
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledWith('/api/v1/podcast-episodes/continue?size=20')
+ expect(wrapper.findAll('button[aria-label^="Resume "]')).toHaveLength(2)
+ expect(wrapper.text()).toContain('Orbit Radio')
+ expect(wrapper.text()).toContain('50m left')
+ })
+
+ it('clamps the requested size to the endpoint maximum', async () => {
+ apiMock.mockImplementation(() => Promise.resolve(jsonResponse([])))
+ mountScroller(500)
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledWith('/api/v1/podcast-episodes/continue?size=50')
+ })
+
+ it('resumes an episode that is not loaded and toggles the one that is', async () => {
+ apiMock.mockImplementation(() => Promise.resolve(jsonResponse([episode(1)])))
+ const wrapper = mountScroller()
+ await flushPromises()
+
+ await wrapper.get('button[aria-label="Resume Episode 1"]').trigger('click')
+ expect(playerState.playInline).toHaveBeenCalledWith(1)
+ expect(playerState.togglePlayback).not.toHaveBeenCalled()
+
+ playerState.episode!.value = { id: 1, finished: false }
+ await nextTick()
+ await wrapper.get('button[aria-label="Resume Episode 1"]').trigger('click')
+
+ expect(playerState.playInline).toHaveBeenCalledTimes(1)
+ expect(playerState.togglePlayback).toHaveBeenCalledTimes(1)
+ })
+
+ it('reloads once the active episode finishes so it drops out of the shelf', async () => {
+ apiMock.mockResolvedValueOnce(jsonResponse([episode(1)])).mockResolvedValueOnce(jsonResponse([episode(2)]))
+ const wrapper = mountScroller()
+ await flushPromises()
+
+ playerState.episode!.value = { id: 1, finished: true }
+ await nextTick()
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledTimes(2)
+ expect(wrapper.text()).toContain('Episode 2')
+ expect(wrapper.text()).not.toContain('Episode 1')
+ })
+
+ it('shows the empty state when nothing is in progress', async () => {
+ apiMock.mockImplementation(() => Promise.resolve(jsonResponse([])))
+ const wrapper = mountScroller()
+ await flushPromises()
+
+ expect(wrapper.findAll('button[aria-label^="Resume "]')).toHaveLength(0)
+ expect(wrapper.text()).toContain('No podcast episodes in progress yet')
+ })
+
+ it('offers a retry after a failed request', async () => {
+ apiMock.mockResolvedValueOnce(new Response(null, { status: 500 })).mockResolvedValueOnce(jsonResponse([episode(1)]))
+ const wrapper = mountScroller()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('Failed to load.')
+
+ await wrapper.get('button.text-primary').trigger('click')
+ await flushPromises()
+
+ expect(wrapper.findAll('button[aria-label^="Resume "]')).toHaveLength(1)
+ })
+})
+
+function mountScroller(limit = 20) {
+ const wrapper = mount(DashboardPodcastScroller, { props: { title: 'Continue Podcasts', limit } })
+ wrappers.push(wrapper)
+ return wrapper
+}
+
+function jsonResponse(items: PodcastEpisodeListItem[]) {
+ return new Response(JSON.stringify(items), { status: 200, headers: { 'Content-Type': 'application/json' } })
+}
+
+function episode(id: number): PodcastEpisodeListItem {
+ return {
+ id,
+ libraryId: 7,
+ origin: 'feed',
+ podcastId: 12,
+ podcastTitle: 'Orbit Radio',
+ podcastImageUrl: null,
+ title: `Episode ${id}`,
+ season: null,
+ episode: null,
+ explicit: false,
+ inFeed: true,
+ publishedAt: '2026-07-29T00:00:00.000Z',
+ durationSeconds: 3_600,
+ audioFormat: null,
+ mediaStatus: 'remote',
+ localSizeBytes: null,
+ checksum: null,
+ positionSeconds: 600,
+ progressPercent: 16.67,
+ finished: false,
+ pinned: false,
+ queued: false,
+ lastListenedAt: '2026-07-29T01:00:00.000Z',
+ }
+}
diff --git a/client/src/features/dashboard/components/DashboardPodcastScroller.vue b/client/src/features/dashboard/components/DashboardPodcastScroller.vue
new file mode 100644
index 000000000..9691b138c
--- /dev/null
+++ b/client/src/features/dashboard/components/DashboardPodcastScroller.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
diff --git a/client/src/features/dashboard/components/DashboardScroller.spec.ts b/client/src/features/dashboard/components/DashboardScroller.spec.ts
index aa659cf34..35c0fdd9d 100644
--- a/client/src/features/dashboard/components/DashboardScroller.spec.ts
+++ b/client/src/features/dashboard/components/DashboardScroller.spec.ts
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick, ref, type Ref } from 'vue'
-import type { BookCard, ScrollerType } from '@bookorbit/types'
+import type { BookCard, BookScrollerType } from '@bookorbit/types'
type DashboardScrollerState = {
books: Ref
@@ -10,7 +10,7 @@ type DashboardScrollerState = {
refresh: () => Promise
}
-type UseDashboardScrollerMock = (type: ScrollerType, limit?: number, smartScopeId?: number) => DashboardScrollerState
+type UseDashboardScrollerMock = (type: BookScrollerType, limit?: number, smartScopeId?: number) => DashboardScrollerState
// `sm` is true at or above the Tailwind sm breakpoint, so a false value is the
// compact viewport where the row count gets capped. A real ref keeps the mock
@@ -134,7 +134,7 @@ function mountScroller({
limit,
refresh = vi.fn<() => Promise>().mockResolvedValue(undefined),
}: {
- type: ScrollerType
+ type: BookScrollerType
books?: BookCard[]
loading?: boolean
error?: boolean
diff --git a/client/src/features/dashboard/components/DashboardScroller.vue b/client/src/features/dashboard/components/DashboardScroller.vue
index 74fd974ff..2729752d7 100644
--- a/client/src/features/dashboard/components/DashboardScroller.vue
+++ b/client/src/features/dashboard/components/DashboardScroller.vue
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { Aperture, BookMarked, BookmarkPlus, ChevronLeft, ChevronRight, Headphones, ListOrdered, RefreshCw, Shuffle, Sparkles } from '@lucide/vue'
-import type { BookCard, ScrollerType } from '@bookorbit/types'
+import type { BookCard, BookScrollerType } from '@bookorbit/types'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
import BookQuickView from '@/features/book/components/BookQuickView.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
@@ -18,7 +18,7 @@ defineOptions({
})
const props = defineProps<{
- type: ScrollerType
+ type: BookScrollerType
title: string
limit?: number
rows?: number
diff --git a/client/src/features/dashboard/components/DashboardSettingsSheet.spec.ts b/client/src/features/dashboard/components/DashboardSettingsSheet.spec.ts
index 3732af23c..72a1d1d69 100644
--- a/client/src/features/dashboard/components/DashboardSettingsSheet.spec.ts
+++ b/client/src/features/dashboard/components/DashboardSettingsSheet.spec.ts
@@ -13,6 +13,12 @@ import pt from '@/locales/pt.json'
// behind a new dashboard key.
const PT_WIDGET_NAMES = { ...en.dashboard.settings.widgetNames, ...pt.dashboard.settings.widgetNames }
const PT_SHELF_NAMES = { ...en.dashboard.settings.shelfNames, ...pt.dashboard.settings.shelfNames }
+const ENABLED_EN_SHELF_NAMES = Object.entries(en.dashboard.settings.shelfNames)
+ .filter(([key]) => key !== 'continuePodcasts')
+ .map(([, label]) => label)
+const ENABLED_PT_SHELF_NAMES = Object.entries(PT_SHELF_NAMES)
+ .filter(([key]) => key !== 'continuePodcasts')
+ .map(([, label]) => label)
type UseSmartScopesMock = () => {
smartScopes: Ref
@@ -203,7 +209,7 @@ describe('DashboardSettingsSheet', () => {
const wrapper = await openSheet()
await openShelvesTab(wrapper)
- expect(shelfOptionLabels(wrapper).sort()).toEqual(Object.values(en.dashboard.settings.shelfNames).sort())
+ expect(shelfOptionLabels(wrapper).sort()).toEqual(ENABLED_EN_SHELF_NAMES.sort())
})
it('offers wide-row and two-column shelf layouts', async () => {
@@ -229,7 +235,7 @@ describe('DashboardSettingsSheet', () => {
await setI18nLocale('pt')
await nextTick()
- expect(shelfOptionLabels(wrapper).sort()).toEqual(Object.values(PT_SHELF_NAMES).sort())
+ expect(shelfOptionLabels(wrapper).sort()).toEqual(ENABLED_PT_SHELF_NAMES.sort())
})
it('opens on the shelves tab and lists it before widgets', async () => {
diff --git a/client/src/features/dashboard/components/DashboardSettingsSheet.vue b/client/src/features/dashboard/components/DashboardSettingsSheet.vue
index 6c1cfd47f..073d617d0 100644
--- a/client/src/features/dashboard/components/DashboardSettingsSheet.vue
+++ b/client/src/features/dashboard/components/DashboardSettingsSheet.vue
@@ -3,7 +3,7 @@ import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronDown, ChevronUp, Columns2, GripVertical, Plus, RotateCcw, Rows3, Trash2 } from '@lucide/vue'
-import type { ScrollerConfig, ScrollerType, WidgetConfig } from '@bookorbit/types'
+import { APP_FEATURES, type ScrollerConfig, type ScrollerType, type WidgetConfig } from '@bookorbit/types'
import { formatList } from '@/i18n/formatters'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { useSmartScopes } from '@/features/smart-scope/composables/useSmartScopes'
@@ -81,6 +81,7 @@ const {
const ALL_TYPES: ScrollerType[] = [
'continue-reading',
'continue-listening',
+ ...(APP_FEATURES.podcasts ? (['continue-podcasts'] as const) : []),
'want-to-read',
'up-next-in-series',
'recently-added',
diff --git a/client/src/features/dashboard/components/widgets/__tests__/CurrentlyReadingWidget.spec.ts b/client/src/features/dashboard/components/widgets/__tests__/CurrentlyReadingWidget.spec.ts
index 5e40acb16..904ce287f 100644
--- a/client/src/features/dashboard/components/widgets/__tests__/CurrentlyReadingWidget.spec.ts
+++ b/client/src/features/dashboard/components/widgets/__tests__/CurrentlyReadingWidget.spec.ts
@@ -25,7 +25,19 @@ const CoverArtworkStub = {
}
function book(fileFormat: string | null) {
- return { bookId: 1, title: 'Saga', authors: ['Brian K. Vaughan'], progress: 40, hasCover: true, fileId: 9, fileFormat }
+ return {
+ bookId: 1,
+ title: 'Saga',
+ authors: ['Brian K. Vaughan'],
+ progress: 40,
+ hasCover: true,
+ fileId: 9,
+ fileFormat,
+ readFileId: 9,
+ readFileFormat: fileFormat,
+ readAlongFileId: null,
+ hasAudio: false,
+ }
}
function mountWidget(fileFormat: string | null) {
diff --git a/client/src/features/dashboard/composables/__tests__/useDashboardConfig.spec.ts b/client/src/features/dashboard/composables/__tests__/useDashboardConfig.spec.ts
index 080e85c7c..b6589b373 100644
--- a/client/src/features/dashboard/composables/__tests__/useDashboardConfig.spec.ts
+++ b/client/src/features/dashboard/composables/__tests__/useDashboardConfig.spec.ts
@@ -60,8 +60,8 @@ describe('useDashboardConfig', () => {
['random', 'Discover Something New', true, 2],
['continue-reading', 'Continue Reading', true, 3],
['continue-listening', 'Continue Listening', true, 4],
- ['want-to-read', 'Want to Read', false, 5],
- ['up-next-in-series', 'Up Next in Series', false, 6],
+ ['want-to-read', 'Want to Read', false, 6],
+ ['up-next-in-series', 'Up Next in Series', false, 7],
])
addScroller('smart-scope')
diff --git a/client/src/features/dashboard/composables/__tests__/useDashboardLabels.spec.ts b/client/src/features/dashboard/composables/__tests__/useDashboardLabels.spec.ts
index f7d34afa8..ba8c7bdba 100644
--- a/client/src/features/dashboard/composables/__tests__/useDashboardLabels.spec.ts
+++ b/client/src/features/dashboard/composables/__tests__/useDashboardLabels.spec.ts
@@ -64,6 +64,7 @@ const SHELF_KEY_BY_TYPE: Record = {
'recently-added': 'recentlyAdded',
'continue-reading': 'continueReading',
'continue-listening': 'continueListening',
+ 'continue-podcasts': 'continuePodcasts',
'want-to-read': 'wantToRead',
'up-next-in-series': 'upNextInSeries',
random: 'random',
diff --git a/client/src/features/dashboard/composables/__tests__/useDashboardScroller.spec.ts b/client/src/features/dashboard/composables/__tests__/useDashboardScroller.spec.ts
index 321bfc917..f1d75501c 100644
--- a/client/src/features/dashboard/composables/__tests__/useDashboardScroller.spec.ts
+++ b/client/src/features/dashboard/composables/__tests__/useDashboardScroller.spec.ts
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent } from 'vue'
-import type { ScrollerType } from '@bookorbit/types'
+import type { BookScrollerType } from '@bookorbit/types'
const bookEventsMock = vi.hoisted(() => ({
progressChangedCallback: null as (() => void) | null,
@@ -44,7 +44,7 @@ function batchBody(): { items: Array<{ id: string; type: string; limit: number;
return JSON.parse(String(init?.body))
}
-function mountComposable(type: ScrollerType, limit = 20, smartScopeId?: number) {
+function mountComposable(type: BookScrollerType, limit = 20, smartScopeId?: number) {
let result!: ReturnType
mount(
defineComponent({
diff --git a/client/src/features/dashboard/composables/useDashboardConfig.ts b/client/src/features/dashboard/composables/useDashboardConfig.ts
index ea8bf5486..2613833b6 100644
--- a/client/src/features/dashboard/composables/useDashboardConfig.ts
+++ b/client/src/features/dashboard/composables/useDashboardConfig.ts
@@ -1,6 +1,6 @@
import { ref } from 'vue'
-import { SCROLLER_TYPES, type ScrollerConfig, type ScrollerType } from '@bookorbit/types'
+import { APP_FEATURES, SCROLLER_TYPES, type ScrollerConfig, type ScrollerType } from '@bookorbit/types'
import { normalizeShelfRows } from '../lib/shelf-rows'
const STORAGE_KEY = 'bookorbit:dashboard:config'
@@ -18,20 +18,24 @@ interface StoredDashboardConfig {
shelfLayout: DashboardShelfLayout
}
-export const DEFAULT_SCROLLERS: ScrollerConfig[] = [
+const ALL_DEFAULT_SCROLLERS: ScrollerConfig[] = [
{ id: '2', type: 'recently-added', label: 'Recently Added', enabled: true, order: 1, limit: 20, rows: 1 },
{ id: '3', type: 'random', label: 'Discover Something New', enabled: true, order: 2, limit: 20, rows: 1 },
{ id: '1', type: 'continue-reading', label: 'Continue Reading', enabled: true, order: 3, limit: 20, rows: 1 },
{ id: '5', type: 'continue-listening', label: 'Continue Listening', enabled: true, order: 4, limit: 20, rows: 1 },
- { id: '6', type: 'want-to-read', label: 'Want to Read', enabled: false, order: 5, limit: 20, rows: 1 },
- { id: '4', type: 'up-next-in-series', label: 'Up Next in Series', enabled: false, order: 6, limit: 20, rows: 1 },
+ { id: '7', type: 'continue-podcasts', label: 'Continue Podcasts', enabled: false, order: 5, limit: 20, rows: 1 },
+ { id: '6', type: 'want-to-read', label: 'Want to Read', enabled: false, order: 6, limit: 20, rows: 1 },
+ { id: '4', type: 'up-next-in-series', label: 'Up Next in Series', enabled: false, order: 7, limit: 20, rows: 1 },
]
+export const DEFAULT_SCROLLERS = ALL_DEFAULT_SCROLLERS.filter((scroller) => APP_FEATURES.podcasts || scroller.type !== 'continue-podcasts')
+
// Persisted-only. Shelf headings and the type selector resolve their text from the active
// locale via useDashboardLabels(); these values just keep stored configs shaped as before.
export const SCROLLER_LABELS: Record = {
'continue-reading': 'Continue Reading',
'continue-listening': 'Continue Listening',
+ 'continue-podcasts': 'Continue Podcasts',
'want-to-read': 'Want to Read',
'up-next-in-series': 'Up Next in Series',
'recently-added': 'Recently Added',
@@ -39,7 +43,7 @@ export const SCROLLER_LABELS: Record = {
'smart-scope': 'Smart Scope',
}
-const VALID_SCROLLER_TYPES = new Set(SCROLLER_TYPES)
+const VALID_SCROLLER_TYPES = new Set(SCROLLER_TYPES.filter((type) => APP_FEATURES.podcasts || type !== 'continue-podcasts'))
function cloneDefaultScrollers(): ScrollerConfig[] {
return DEFAULT_SCROLLERS.map((scroller) => ({ ...scroller }))
diff --git a/client/src/features/dashboard/composables/useDashboardLabels.ts b/client/src/features/dashboard/composables/useDashboardLabels.ts
index d73521cb4..a77f8fd8a 100644
--- a/client/src/features/dashboard/composables/useDashboardLabels.ts
+++ b/client/src/features/dashboard/composables/useDashboardLabels.ts
@@ -27,6 +27,7 @@ const SHELF_NAME_KEYS: Record = {
'recently-added': 'recentlyAdded',
'continue-reading': 'continueReading',
'continue-listening': 'continueListening',
+ 'continue-podcasts': 'continuePodcasts',
'want-to-read': 'wantToRead',
'up-next-in-series': 'upNextInSeries',
random: 'random',
diff --git a/client/src/features/dashboard/composables/useDashboardPodcastScroller.ts b/client/src/features/dashboard/composables/useDashboardPodcastScroller.ts
new file mode 100644
index 000000000..def3d6c0e
--- /dev/null
+++ b/client/src/features/dashboard/composables/useDashboardPodcastScroller.ts
@@ -0,0 +1,35 @@
+import { onMounted, ref } from 'vue'
+
+import type { PodcastEpisodeListItem } from '@bookorbit/types'
+import { api } from '@/lib/api'
+
+const MAX_LIMIT = 50
+
+/**
+ * The dashboard's podcast shelf. Unlike the book scrollers this does not go through
+ * `/dashboard/scrollers/:type`: podcast progress lives in the podcast module, which already exposes
+ * a cross-library resume feed scoped to the libraries the user can reach.
+ */
+export function useDashboardPodcastScroller(limit = 20) {
+ const episodes = ref([])
+ const loading = ref(true)
+ const error = ref(false)
+
+ async function load() {
+ loading.value = true
+ error.value = false
+ try {
+ const size = Math.min(Math.max(1, Math.trunc(limit)), MAX_LIMIT)
+ const res = await api(`/api/v1/podcast-episodes/continue?size=${size}`)
+ if (!res.ok) throw new Error()
+ episodes.value = await res.json()
+ } catch {
+ error.value = true
+ } finally {
+ loading.value = false
+ }
+ }
+
+ onMounted(load)
+ return { episodes, loading, error, refresh: load }
+}
diff --git a/client/src/features/dashboard/composables/useDashboardScroller.ts b/client/src/features/dashboard/composables/useDashboardScroller.ts
index 02d3fcdb4..1e869479f 100644
--- a/client/src/features/dashboard/composables/useDashboardScroller.ts
+++ b/client/src/features/dashboard/composables/useDashboardScroller.ts
@@ -3,10 +3,10 @@ import { onMounted, ref } from 'vue'
import {
DASHBOARD_SCROLLER_BATCH_MAX,
type BookCard,
+ type BookScrollerType,
type DashboardScrollerBatchRequest,
type DashboardScrollerBatchResponse,
type DashboardScrollerBatchResult,
- type ScrollerType,
} from '@bookorbit/types'
import { api } from '@/lib/api'
import { useBookProgressRefresh } from '@/features/book/composables/useBookProgressRefresh'
@@ -54,7 +54,7 @@ async function flushBatch(): Promise {
}
}
-function requestScroller(type: ScrollerType, limit: number, smartScopeId?: number): Promise {
+function requestScroller(type: BookScrollerType, limit: number, smartScopeId?: number): Promise {
return new Promise((resolve, reject) => {
requestSequence += 1
pendingRequests.push({
@@ -71,7 +71,7 @@ function requestScroller(type: ScrollerType, limit: number, smartScopeId?: numbe
})
}
-export function useDashboardScroller(type: ScrollerType, limit = 20, smartScopeId?: number) {
+export function useDashboardScroller(type: BookScrollerType, limit = 20, smartScopeId?: number) {
const books = ref([])
const loading = ref(true)
const error = ref(false)
diff --git a/client/src/features/library/components/LibraryCreatorDetails.vue b/client/src/features/library/components/LibraryCreatorDetails.vue
index 697e67c4d..826a072b5 100644
--- a/client/src/features/library/components/LibraryCreatorDetails.vue
+++ b/client/src/features/library/components/LibraryCreatorDetails.vue
@@ -1,7 +1,7 @@
@@ -96,17 +113,24 @@ function handlePrescan() {
{{ t('library.creator.folders.browseTitle') }}
- {{ t('library.creator.folders.browseDescription') }}
+
+ {{
+ libraryType === 'podcasts' ? 'Choose where downloaded podcast episodes will be stored.' : t('library.creator.folders.browseDescription')
+ }}
+
-
{{ t('library.creator.folders.selectedTitle') }}
+
+ {{ libraryType === 'podcasts' ? 'Storage folder' : t('library.creator.folders.selectedTitle') }}
+
{{ folders.length }}
-
+
@@ -202,5 +226,12 @@ function handlePrescan() {
+
+
diff --git a/client/src/features/library/components/LibraryCreatorLocalFolders.vue b/client/src/features/library/components/LibraryCreatorLocalFolders.vue
new file mode 100644
index 000000000..047a3d4ed
--- /dev/null
+++ b/client/src/features/library/components/LibraryCreatorLocalFolders.vue
@@ -0,0 +1,134 @@
+
+
+
+
+
+
{{ t('library.creator.folders.local.title') }}
+
+ {{ localFolders.length > 0 ? localFolders.length : t('library.creator.folders.local.optional') }}
+
+
+ {{ t('library.creator.folders.local.description') }}
+
+
+
+
+
+
+ {{ t('library.creator.folders.local.addTitle') }}
+ {{ t('library.creator.folders.local.addDescription') }}
+
+
+
+
+
+
+
+ {{ t('library.creator.folders.local.add') }}
+
+
+
+
+
+
+ {{ t('library.creator.folders.manualEntry') }}
+
+
+
+
+
+
+
+
diff --git a/client/src/features/library/components/LibraryCreatorModal.vue b/client/src/features/library/components/LibraryCreatorModal.vue
index 0a2ff1830..867ac24b8 100644
--- a/client/src/features/library/components/LibraryCreatorModal.vue
+++ b/client/src/features/library/components/LibraryCreatorModal.vue
@@ -17,7 +17,7 @@ import {
Users,
X,
} from '@lucide/vue'
-import type { CoverAspectRatio, Library, OrganizationMode } from '@bookorbit/types'
+import { APP_FEATURES, type CoverAspectRatio, type Library, type LibraryType, type OrganizationMode } from '@bookorbit/types'
import { useLibraryAddedAt } from '../composables/useLibraryAddedAt'
import { useModal } from '@/composables/useModal'
import { api } from '@/lib/api'
@@ -35,6 +35,8 @@ const { t } = useI18n()
const props = defineProps<{
library?: Library | null
+ /** Create mode only: pre-selects the library type (e.g. from the podcast sidebar section). */
+ initialType?: LibraryType
}>()
const emit = defineEmits<{
@@ -63,7 +65,7 @@ const ALL_SECTIONS: CreatorSection[] = [
{
id: 'folders',
label: 'Folders',
- description: 'Choose the server folders that contain your books.',
+ description: 'Choose the server folders used by this library.',
icon: FolderOpen,
component: LibraryCreatorFolders,
required: true,
@@ -124,8 +126,22 @@ const nestedModalOpen = ref(false)
const addedAtRecompute = useLibraryAddedAt(editingLibraryId)
const attemptedSections = ref(new Set())
-const sections = computed(() => (mode.value === 'create' ? ALL_SECTIONS.filter((section) => section.id !== 'access') : ALL_SECTIONS))
-const activeSection = computed(() => sections.value[stepIndex.value] ?? sections.value[0]!)
+const sections = computed(() => {
+ if (form.type === 'podcasts') {
+ return ALL_SECTIONS.filter(
+ (section) =>
+ section.id === 'details' || section.id === 'folders' || section.id === 'schedule' || (mode.value === 'edit' && section.id === 'access'),
+ )
+ }
+ return mode.value === 'create' ? ALL_SECTIONS.filter((section) => section.id !== 'access') : ALL_SECTIONS
+})
+const activeSection = computed(() => {
+ const section = sections.value[stepIndex.value] ?? sections.value[0]!
+ if (form.type === 'podcasts' && section.id === 'schedule') {
+ return { ...section, description: t('library.creator.schedule.watchFolders.hint') }
+ }
+ return section
+})
const ActiveComponent = computed(() => activeSection.value.component)
const activeId = computed(() => activeSection.value.id)
const isFirstStep = computed(() => stepIndex.value === 0)
@@ -145,8 +161,14 @@ watch(
)
const sectionProps = computed(() => ({
- details: { name: form.name, icon: form.icon, coverAspectRatio: form.coverAspectRatio },
- folders: { folders: form.folders, prescanResult: prescanResult.value, prescanLoading: prescanLoading.value },
+ details: { name: form.name, icon: form.icon, coverAspectRatio: form.coverAspectRatio, type: form.type, typeLocked: mode.value === 'edit' },
+ folders: {
+ folders: form.folders,
+ localFolders: form.localFolders,
+ prescanResult: prescanResult.value,
+ prescanLoading: prescanLoading.value,
+ libraryType: form.type,
+ },
scanner: {
organizationMode: form.organizationMode,
organizationModeLocked: mode.value === 'edit',
@@ -164,7 +186,11 @@ const sectionProps = computed(() => ({
readingThreshold: form.readingThreshold,
markAsFinishedPercentComplete: form.markAsFinishedPercentComplete,
},
- schedule: { watch: form.watch, autoScanCronExpression: form.autoScanCronExpression },
+ schedule: {
+ watch: form.type === 'podcasts' ? form.watchLocalFolders : form.watch,
+ autoScanCronExpression: form.autoScanCronExpression,
+ showAutoScanSchedule: form.type === 'books',
+ },
fileWrite: {
fileRenameEnabled: form.fileRenameEnabled,
fileWriteEnabled: form.fileWriteEnabled,
@@ -251,6 +277,19 @@ function handleIconUpdate(value: string | null) {
form.icon = value
}
+function handleTypeUpdate(value: LibraryType) {
+ const availableType = APP_FEATURES.podcasts ? value : 'books'
+ form.type = availableType
+ if (availableType === 'podcasts') {
+ form.coverAspectRatio = '1/1'
+ form.watchLocalFolders = true
+ if (!form.name.trim()) form.name = 'Podcasts'
+ if (!form.icon) form.icon = 'Podcast'
+ }
+ stepIndex.value = 0
+ visitedUpTo.value = 0
+}
+
function handleCoverAspectRatioUpdate(value: CoverAspectRatio) {
form.coverAspectRatio = value
}
@@ -260,6 +299,10 @@ function handleFoldersUpdate(value: string[]) {
prescanResult.value = null
}
+function handleLocalFoldersUpdate(value: string[]) {
+ form.localFolders = value
+}
+
function handleOrganizationModeUpdate(value: OrganizationMode) {
form.organizationMode = value
}
@@ -278,9 +321,11 @@ function handleNestedModalChange(value: boolean) {
const sectionListeners = {
'update:name': handleNameUpdate,
+ 'update:type': handleTypeUpdate,
'update:icon': handleIconUpdate,
'update:coverAspectRatio': handleCoverAspectRatioUpdate,
'update:folders': handleFoldersUpdate,
+ 'update:localFolders': handleLocalFoldersUpdate,
'update:organizationMode': handleOrganizationModeUpdate,
'update:addedAtSource': handleAddedAtSourceUpdate,
recompute: handleRecomputeAddedAt,
@@ -290,7 +335,10 @@ const sectionListeners = {
'update:excludePatterns': (value: string[]) => (form.excludePatterns = value),
'update:readingThreshold': (value: number) => (form.readingThreshold = value),
'update:markAsFinishedPercentComplete': (value: number) => (form.markAsFinishedPercentComplete = value),
- 'update:watch': (value: boolean) => (form.watch = value),
+ 'update:watch': (value: boolean) => {
+ if (form.type === 'podcasts') form.watchLocalFolders = value
+ else form.watch = value
+ },
'update:autoScanCronExpression': (value: string | null) => (form.autoScanCronExpression = value),
'update:fileRenameEnabled': (value: boolean) => (form.fileRenameEnabled = value),
'update:fileWriteEnabled': (value: boolean) => (form.fileWriteEnabled = value),
@@ -329,6 +377,7 @@ onMounted(async () => {
visitedUpTo.value = ALL_SECTIONS.length - 1
} else {
creator.initCreate()
+ if (props.initialType && props.initialType !== form.type) handleTypeUpdate(props.initialType)
visitedUpTo.value = 0
}
} catch {
@@ -338,6 +387,7 @@ onMounted(async () => {
initializationWarning.value = 'The latest library settings could not be loaded. You can still edit the cached settings shown here.'
} else {
creator.initCreate()
+ if (props.initialType && props.initialType !== form.type) handleTypeUpdate(props.initialType)
}
} finally {
stepIndex.value = 0
diff --git a/client/src/features/library/components/LibraryCreatorSchedule.vue b/client/src/features/library/components/LibraryCreatorSchedule.vue
index 5bfad9361..3426cbb1c 100644
--- a/client/src/features/library/components/LibraryCreatorSchedule.vue
+++ b/client/src/features/library/components/LibraryCreatorSchedule.vue
@@ -8,10 +8,14 @@ import { parseCronToHuman } from '@/features/library/utils/cron'
const { t, locale } = useI18n()
-const props = defineProps<{
- watch: boolean
- autoScanCronExpression: string | null
-}>()
+const props = withDefaults(
+ defineProps<{
+ watch: boolean
+ autoScanCronExpression: string | null
+ showAutoScanSchedule?: boolean
+ }>(),
+ { showAutoScanSchedule: true },
+)
const emit = defineEmits<{
'update:watch': [value: boolean]
@@ -92,7 +96,7 @@ function humanReadableCron(cron: string | null): string {
-
+
{{ t('library.creator.schedule.autoScanSchedule') }}
{{ t('library.creator.schedule.timezoneHint') }}
diff --git a/client/src/features/library/components/__tests__/LibraryCreatorFolders.spec.ts b/client/src/features/library/components/__tests__/LibraryCreatorFolders.spec.ts
index 8fb15e826..791262f74 100644
--- a/client/src/features/library/components/__tests__/LibraryCreatorFolders.spec.ts
+++ b/client/src/features/library/components/__tests__/LibraryCreatorFolders.spec.ts
@@ -27,19 +27,35 @@ describe('LibraryCreatorFolders', () => {
expect(wrapper.text()).toContain('Accessible · 1,250 files')
expect(wrapper.text()).toContain('1,250 matching files across 1 folder.')
})
+
+ it('offers existing podcast folders only for podcast libraries', () => {
+ expect(mountFolders({ libraryType: 'books' }).text()).not.toContain('Existing podcast folders')
+ expect(mountFolders({ libraryType: 'podcasts' }).text()).toContain('Existing podcast folders')
+ })
+
+ it('reports the podcast local folders it was given as removable rows', () => {
+ const wrapper = mountFolders({ libraryType: 'podcasts', folders: ['/podcasts'], localFolders: ['/archive/shows'] })
+
+ expect(wrapper.text()).toContain('/archive/shows')
+ expect(wrapper.find('button[aria-label="Remove /archive/shows"]').exists()).toBe(true)
+ })
})
function mountFolders(
props: {
folders?: string[]
+ localFolders?: string[]
+ libraryType?: 'books' | 'podcasts'
prescanResult?: { totalFiles: number; paths: { path: string; accessible: boolean; fileCount: number }[] } | null
} = {},
) {
return mount(LibraryCreatorFolders, {
props: {
folders: props.folders ?? [],
+ localFolders: props.localFolders ?? [],
prescanResult: props.prescanResult ?? null,
prescanLoading: false,
+ libraryType: props.libraryType ?? 'books',
},
global: {
stubs: {
diff --git a/client/src/features/library/components/__tests__/LibraryCreatorModal.spec.ts b/client/src/features/library/components/__tests__/LibraryCreatorModal.spec.ts
index 76422b552..eb5f4d98b 100644
--- a/client/src/features/library/components/__tests__/LibraryCreatorModal.spec.ts
+++ b/client/src/features/library/components/__tests__/LibraryCreatorModal.spec.ts
@@ -1,10 +1,11 @@
-import { flushPromises, shallowMount } from '@vue/test-utils'
+import { flushPromises, mount, shallowMount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Library } from '@bookorbit/types'
import { useLibraryCreator } from '../../composables/useLibraryCreator'
import LibraryCreatorScanner from '../LibraryCreatorScanner.vue'
import LibraryCreatorDetails from '../LibraryCreatorDetails.vue'
import LibraryCreatorModal from '../LibraryCreatorModal.vue'
+import LibraryCreatorSchedule from '../LibraryCreatorSchedule.vue'
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise
>())
@@ -67,7 +68,7 @@ describe('LibraryCreatorModal', () => {
const continueButton = wrapper.findAll('button').find((button) => button.text().includes('Continue'))
expect(continueButton?.attributes('disabled')).toBeUndefined()
await continueButton?.trigger('click')
- expect(wrapper.text()).toContain('Choose the server folders that contain your books.')
+ expect(wrapper.text()).toContain('Choose the server folders used by this library.')
wrapper.unmount()
})
@@ -86,4 +87,29 @@ describe('LibraryCreatorModal', () => {
expect(wrapper.get('[role="alert"]').text()).toContain('Enter a library name.')
wrapper.unmount()
})
+
+ it('clamps attempted podcast creation to a book library', async () => {
+ const wrapper = shallowMount(LibraryCreatorModal, {
+ attachTo: document.body,
+ global: { stubs: { teleport: true } },
+ })
+ await flushPromises()
+
+ const details = wrapper.getComponent(LibraryCreatorDetails)
+ details.vm.$emit('update:type', 'podcasts')
+ await wrapper.vm.$nextTick()
+
+ expect(details.props('type')).toBe('books')
+
+ wrapper.unmount()
+ })
+
+ it('renders only folder watching when scan scheduling is unavailable', () => {
+ const wrapper = mount(LibraryCreatorSchedule, {
+ props: { watch: true, autoScanCronExpression: null, showAutoScanSchedule: false },
+ })
+
+ expect(wrapper.text()).toContain('Watch folders')
+ expect(wrapper.text()).not.toContain('Auto-scan schedule')
+ })
})
diff --git a/client/src/features/library/composables/__tests__/useLibraries.spec.ts b/client/src/features/library/composables/__tests__/useLibraries.spec.ts
index 114c3940d..3e3175ad9 100644
--- a/client/src/features/library/composables/__tests__/useLibraries.spec.ts
+++ b/client/src/features/library/composables/__tests__/useLibraries.spec.ts
@@ -10,6 +10,7 @@ vi.mock('@/lib/api', () => ({
function makeLibrary(overrides: Partial = {}): Library {
return {
id: 3,
+ type: 'books',
name: 'Main Library',
icon: null,
displayOrder: 0,
diff --git a/client/src/features/library/composables/__tests__/useLibraryCreator.spec.ts b/client/src/features/library/composables/__tests__/useLibraryCreator.spec.ts
index e40dad69d..96e022d18 100644
--- a/client/src/features/library/composables/__tests__/useLibraryCreator.spec.ts
+++ b/client/src/features/library/composables/__tests__/useLibraryCreator.spec.ts
@@ -130,6 +130,7 @@ describe('useLibraryCreator', () => {
creator.initEdit({
id: 1,
+ type: 'books',
name: 'Main Library',
icon: 'BookOpen',
displayOrder: 0,
@@ -160,7 +161,7 @@ describe('useLibraryCreator', () => {
fileWriteAudioEnabled: false,
fileWriteAudioMaxFileSizeMb: 750,
fileRenameEnabled: true,
- folders: [{ id: 1, path: '/books', createdAt: '2026-01-01T00:00:00.000Z' }],
+ folders: [{ id: 1, path: '/books', role: 'downloads' as const, createdAt: '2026-01-01T00:00:00.000Z' }],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
})
@@ -191,14 +192,42 @@ describe('useLibraryCreator', () => {
body: expect.any(String),
}),
)
- expect(JSON.parse(apiMock.mock.calls[0]?.[1]?.body as string)).toMatchObject({
+ const payload = JSON.parse(apiMock.mock.calls[0]?.[1]?.body as string)
+ expect(payload).toMatchObject({
name: 'Audio',
fileWriteAudioEnabled: true,
fileWriteAudioMaxFileSizeMb: 750,
})
+ expect(payload).not.toHaveProperty('localFolders')
+ expect(payload).not.toHaveProperty('watchLocalFolders')
expect(creator.loading.value).toBe(false)
})
+ it('sends the podcast local-folder watcher setting without book automation fields', async () => {
+ const { useLibraryCreator } = await import('../useLibraryCreator')
+ const creator = useLibraryCreator()
+ const saved = makeLibrary({ id: 13, type: 'podcasts', name: 'Podcasts', watchLocalFolders: false })
+ apiMock.mockResolvedValue(jsonResponse(saved, 201))
+
+ creator.form.type = 'podcasts'
+ creator.form.name = 'Podcasts'
+ creator.form.icon = 'Podcast'
+ creator.form.folders = ['/podcasts/downloads']
+ creator.form.localFolders = ['/podcasts/local']
+ creator.form.watchLocalFolders = false
+
+ await expect(creator.save()).resolves.toEqual(saved)
+
+ const payload = JSON.parse(apiMock.mock.calls[0]?.[1]?.body as string)
+ expect(payload).toMatchObject({
+ type: 'podcasts',
+ localFolders: ['/podcasts/local'],
+ watchLocalFolders: false,
+ })
+ expect(payload).not.toHaveProperty('watch')
+ expect(payload).not.toHaveProperty('autoScanCronExpression')
+ })
+
it('trims user-entered values and surfaces save connection failures', async () => {
const { useLibraryCreator } = await import('../useLibraryCreator')
const creator = useLibraryCreator()
@@ -228,6 +257,7 @@ describe('useLibraryCreator', () => {
await expect(creator.save()).resolves.toBeNull()
expect(apiMock).toHaveBeenCalledWith('/api/v1/libraries/12', expect.objectContaining({ method: 'PATCH' }))
+ expect(JSON.parse(apiMock.mock.calls[0]?.[1]?.body as string)).not.toHaveProperty('type')
expect(creator.error.value).toBe('Name already exists')
expect(creator.loading.value).toBe(false)
})
@@ -243,6 +273,7 @@ function jsonResponse(body: unknown, status = 200): Response {
function makeLibrary(overrides: Partial = {}): Library {
return {
id: 1,
+ type: 'books',
name: 'Main Library',
icon: 'BookOpen',
displayOrder: 0,
@@ -273,7 +304,7 @@ function makeLibrary(overrides: Partial = {}): Library {
fileWriteAudioEnabled: false,
fileWriteAudioMaxFileSizeMb: 750,
fileRenameEnabled: true,
- folders: [{ id: 1, path: '/books', createdAt: '2026-01-01T00:00:00.000Z' }],
+ folders: [{ id: 1, path: '/books', role: 'downloads' as const, createdAt: '2026-01-01T00:00:00.000Z' }],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
diff --git a/client/src/features/library/composables/useLibraries.ts b/client/src/features/library/composables/useLibraries.ts
index 6417be7de..3faa768d7 100644
--- a/client/src/features/library/composables/useLibraries.ts
+++ b/client/src/features/library/composables/useLibraries.ts
@@ -1,21 +1,41 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
+import { createCoalescedFetch, createRequestGeneration } from '@/lib/async'
import type { Library } from '@bookorbit/types'
const libraries = ref([])
const loading = ref(false)
const loaded = ref(false)
const error = ref(null)
-let fetchPromise: Promise | null = null
-let requestGeneration = 0
+const listGeneration = createRequestGeneration()
+
+const loadLibraries = createCoalescedFetch(async (): Promise => {
+ loading.value = true
+ error.value = null
+ const generation = listGeneration.current()
+ try {
+ const res = await api('/api/v1/libraries')
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ const data: unknown = await res.json()
+ if (!Array.isArray(data)) throw new Error('Invalid library response')
+ if (!listGeneration.isCurrent(generation)) return
+ libraries.value = data as Library[]
+ loaded.value = true
+ } catch (cause: unknown) {
+ if (!listGeneration.isCurrent(generation)) return
+ error.value = cause instanceof Error ? cause.message : 'Failed to load libraries'
+ } finally {
+ if (listGeneration.isCurrent(generation)) loading.value = false
+ }
+})
export function resetLibraries(): void {
- requestGeneration += 1
+ listGeneration.invalidate()
libraries.value = []
loading.value = false
loaded.value = false
error.value = null
- fetchPromise = null
+ loadLibraries.clear()
}
export function useLibraries() {
@@ -25,30 +45,7 @@ export function useLibraries() {
}
async function refreshLibraries(): Promise {
- if (fetchPromise) return fetchPromise
- loading.value = true
- error.value = null
- const generation = requestGeneration
- fetchPromise = (async () => {
- try {
- const res = await api('/api/v1/libraries')
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
- const data: unknown = await res.json()
- if (!Array.isArray(data)) throw new Error('Invalid library response')
- if (generation !== requestGeneration) return
- libraries.value = data as Library[]
- loaded.value = true
- } catch (cause: unknown) {
- if (generation !== requestGeneration) return
- error.value = cause instanceof Error ? cause.message : 'Failed to load libraries'
- } finally {
- if (generation === requestGeneration) {
- fetchPromise = null
- loading.value = false
- }
- }
- })()
- return fetchPromise
+ return loadLibraries()
}
async function reorderLibraries(order: { id: number; displayOrder: number }[]): Promise {
diff --git a/client/src/features/library/composables/useLibraryCreationRedirect.ts b/client/src/features/library/composables/useLibraryCreationRedirect.ts
index 25acdca74..14f58e0a2 100644
--- a/client/src/features/library/composables/useLibraryCreationRedirect.ts
+++ b/client/src/features/library/composables/useLibraryCreationRedirect.ts
@@ -13,10 +13,10 @@ export function useLibraryCreationRedirect() {
const router = useRouter()
const { refreshLibraries } = useLibraries()
- async function handleLibraryCreated(library: Pick) {
+ async function handleLibraryCreated(library: Pick) {
setDefaultSortForLibrary(library.id)
await refreshLibraries()
- await router.push({ name: 'library', params: { id: library.id } })
+ await router.push({ name: library.type === 'podcasts' ? 'podcast-library' : 'library', params: { id: library.id } })
}
return { handleLibraryCreated }
diff --git a/client/src/features/library/composables/useLibraryCreator.ts b/client/src/features/library/composables/useLibraryCreator.ts
index 20289ffbc..33cab74bb 100644
--- a/client/src/features/library/composables/useLibraryCreator.ts
+++ b/client/src/features/library/composables/useLibraryCreator.ts
@@ -1,7 +1,8 @@
import { computed, reactive, ref } from 'vue'
import { api } from '@/lib/api'
import { DEFAULT_FORMAT_PRIORITY, FORMAT_LABELS, isFiveFieldCronExpression } from '@bookorbit/types'
-import type { AddedAtSource, CoverAspectRatio, Library, OrganizationMode, PrescanResult } from '@bookorbit/types'
+import type { AddedAtSource, CoverAspectRatio, Library, LibraryType, OrganizationMode, PrescanResult } from '@bookorbit/types'
+import { coveringFolderPath, normalizeFolderPath } from './folder-paths'
export { DEFAULT_FORMAT_PRIORITY, FORMAT_LABELS }
@@ -19,12 +20,15 @@ const FILE_SIZE_MAX_MB = 10_000
function blankForm() {
return {
+ type: 'books' as LibraryType,
name: '',
icon: null as string | null,
displayOrder: 0,
coverAspectRatio: '2/3' as CoverAspectRatio,
folders: [] as string[],
+ localFolders: [] as string[],
watch: false,
+ watchLocalFolders: true,
autoScanCronExpression: null as string | null,
metadataPrecedence: [...DEFAULT_METADATA_PRECEDENCE],
formatPriority: [...DEFAULT_FORMAT_PRIORITY] as string[],
@@ -67,6 +71,10 @@ export function useLibraryCreator() {
if (!form.name.trim()) errors.details = 'Enter a library name.'
else if (!form.icon?.trim()) errors.details = 'Choose an icon.'
if (form.folders.length === 0) errors.folders = 'Add at least one folder.'
+ else if (form.type === 'podcasts' && form.folders.length !== 1) errors.folders = 'Choose exactly one storage folder for podcasts.'
+ else if (form.type === 'podcasts' && overlappingPodcastFolder(form.folders, form.localFolders)) {
+ errors.folders = 'Existing podcast folders must sit outside the storage folder.'
+ }
if (form.autoScanCronExpression && !isFiveFieldCronExpression(form.autoScanCronExpression)) {
errors.schedule = 'Enter a valid 5-field cron expression.'
}
@@ -102,12 +110,15 @@ export function useLibraryCreator() {
}
function initEdit(library: Library) {
+ form.type = library.type
form.name = library.name
form.icon = library.icon ?? null
form.displayOrder = library.displayOrder
form.coverAspectRatio = library.coverAspectRatio
- form.folders = library.folders.map((f) => f.path)
+ form.folders = library.folders.filter((f) => f.role !== 'local').map((f) => f.path)
+ form.localFolders = library.folders.filter((f) => f.role === 'local').map((f) => f.path)
form.watch = library.watch
+ form.watchLocalFolders = library.watchLocalFolders ?? true
form.autoScanCronExpression = library.autoScanCronExpression ?? null
form.metadataPrecedence = [...library.metadataPrecedence]
const missing = DEFAULT_FORMAT_PRIORITY.filter((f) => !library.formatPriority.includes(f))
@@ -176,12 +187,22 @@ export function useLibraryCreator() {
error.value = null
loading.value = true
try {
- const payload = {
- ...form,
+ const sharedPayload = {
+ type: form.type,
name: form.name.trim(),
icon: form.icon!.trim(),
+ displayOrder: form.displayOrder,
+ coverAspectRatio: form.coverAspectRatio,
folders: [...new Set(form.folders.map((path) => path.trim()))],
}
+ const payload =
+ form.type === 'podcasts'
+ ? {
+ ...sharedPayload,
+ localFolders: [...new Set(form.localFolders.map((path) => path.trim()))],
+ watchLocalFolders: form.watchLocalFolders,
+ }
+ : { ...form, ...sharedPayload, localFolders: undefined, watchLocalFolders: undefined }
let res: Response
if (mode.value === 'create') {
res = await api('/api/v1/libraries', {
@@ -190,10 +211,11 @@ export function useLibraryCreator() {
body: JSON.stringify(payload),
})
} else {
+ const { type: _type, ...updatePayload } = payload
res = await api(`/api/v1/libraries/${editingLibraryId.value}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: JSON.stringify(updatePayload),
})
}
if (!res.ok) {
@@ -226,6 +248,18 @@ export function useLibraryCreator() {
}
}
+/**
+ * Whether a storage folder and an existing-podcasts folder overlap. The server refuses the same
+ * pairing; catching it here means the wizard says so before the save round-trip.
+ */
+function overlappingPodcastFolder(folders: string[], localFolders: string[]): boolean {
+ const storage = folders.map(normalizeFolderPath)
+ return localFolders.some((candidate) => {
+ const local = normalizeFolderPath(candidate)
+ return storage.some((path) => path === local || coveringFolderPath(local, [path]) !== null || coveringFolderPath(path, [local]) !== null)
+ })
+}
+
async function responseError(response: Response, fallback: string): Promise {
const body = await response.json().catch(() => null)
const message = body?.message
diff --git a/client/src/features/library/views/LibrariesView.vue b/client/src/features/library/views/LibrariesView.vue
index 05dafcba9..dbb61919b 100644
--- a/client/src/features/library/views/LibrariesView.vue
+++ b/client/src/features/library/views/LibrariesView.vue
@@ -18,6 +18,9 @@ const { handleLibraryCreated } = useLibraryCreationRedirect()
const createOpen = ref(false)
const canManageLibraries = computed(() => hasPermission('manage_libraries'))
+/** Podcast libraries have their own index; listing them here would route them to the book view. */
+const bookLibraries = computed(() => libraries.value.filter((library) => library.type !== 'podcasts'))
+
function openCreate() {
createOpen.value = true
}
@@ -38,12 +41,12 @@ onMounted(() => {
-
+
@@ -19,5 +32,6 @@ export const NOTIFICATION_CATEGORY_ICONS: Record Promise>()
+const addPodcastsToCollection = vi.fn<(id: number, ids: number[]) => Promise>()
+const createCollection = vi.fn<(name: string, icon: string, description?: string, mediaType?: string) => Promise>()
+
+const removePodcastsFromCollection = vi.fn<(id: number, ids: number[]) => Promise>()
+const refreshCollections = vi.fn<() => Promise>().mockResolvedValue(undefined)
+
+vi.mock('@/features/collection/composables/useCollections', () => ({
+ useCollections: () => ({
+ fetchPodcastCollectionMembership,
+ addPodcastsToCollection,
+ removePodcastsFromCollection,
+ createCollection,
+ refreshCollections,
+ }),
+}))
+
+const { toastSuccess, toastError } = vi.hoisted(() => ({ toastSuccess: vi.fn<() => void>(), toastError: vi.fn<() => void>() }))
+vi.mock('vue-sonner', () => ({ toast: { success: toastSuccess, error: toastError } }))
+
+vi.mock('@/i18n/formatters', () => ({ formatNumber: (value: number) => String(value) }))
+
+import AddShowToCollectionSheet from './AddShowToCollectionSheet.vue'
+import { sheetStubs } from '../test/stubs'
+
+function makeCollection(id: number, name: string): Collection {
+ return {
+ id,
+ userId: 7,
+ mediaType: 'podcasts',
+ name,
+ icon: 'FolderOpen',
+ description: null,
+ isPublic: false,
+ isOwner: true,
+ syncToKobo: false,
+ displayOrder: 0,
+ bookCount: 0,
+ podcastCount: 1,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ }
+}
+
+function mountSheet() {
+ return mount(AddShowToCollectionSheet, {
+ props: { open: false, show: { id: 7, title: 'Lightspeed' } },
+ global: { stubs: { ...sheetStubs(), AppIcon: true } },
+ })
+}
+
+async function open(wrapper: ReturnType) {
+ await wrapper.setProps({ open: true })
+ await flushPromises()
+}
+
+/** Rows are named by their collection, so a test never has to count buttons to find one. */
+function collectionRow(wrapper: ReturnType, name: string) {
+ return wrapper.findAll('button').find((button) => button.text().includes(name))!
+}
+
+describe('AddShowToCollectionSheet', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ fetchPodcastCollectionMembership.mockResolvedValue([makeCollection(1, 'Sci-fi'), makeCollection(2, 'News')])
+ addPodcastsToCollection.mockResolvedValue(undefined)
+ removePodcastsFromCollection.mockResolvedValue(undefined)
+ createCollection.mockResolvedValue(makeCollection(3, 'Created'))
+ })
+
+ it('lists only podcast collections once opened', async () => {
+ const wrapper = mountSheet()
+ await open(wrapper)
+
+ expect(fetchPodcastCollectionMembership).toHaveBeenCalledWith(7)
+ expect(wrapper.text()).toContain('Sci-fi')
+ expect(wrapper.text()).toContain('News')
+ })
+
+ it('adds the show to the chosen collection', async () => {
+ const wrapper = mountSheet()
+ await open(wrapper)
+
+ await collectionRow(wrapper, 'Sci-fi').trigger('click')
+ await flushPromises()
+
+ expect(addPodcastsToCollection).toHaveBeenCalledWith(1, [7])
+ expect(toastSuccess).toHaveBeenCalled()
+ })
+
+ it('shows membership as a labelled state rather than a dead disabled row', async () => {
+ fetchPodcastCollectionMembership.mockResolvedValue([{ ...makeCollection(1, 'Sci-fi'), memberCount: 1 }, makeCollection(2, 'News')])
+ const wrapper = mountSheet()
+ await open(wrapper)
+
+ expect(wrapper.text()).toContain('In this collection')
+ expect(collectionRow(wrapper, 'Sci-fi').attributes('disabled')).toBeUndefined()
+ })
+
+ it('reports a failed add without marking the show as a member', async () => {
+ addPodcastsToCollection.mockRejectedValue(new Error('boom'))
+ const wrapper = mountSheet()
+ await open(wrapper)
+
+ await collectionRow(wrapper, 'Sci-fi').trigger('click')
+ await flushPromises()
+
+ expect(toastError).toHaveBeenCalled()
+ expect(wrapper.text()).not.toContain('In this collection')
+ })
+
+ it('creates a podcast collection and adds the show to it in one step', async () => {
+ const wrapper = mountSheet()
+ await open(wrapper)
+
+ await wrapper.get('input[aria-label="New collection name"]').setValue('Space stuff')
+ await wrapper
+ .findAll('button')
+ .find((button) => button.text() === 'Create')!
+ .trigger('click')
+ await flushPromises()
+
+ expect(createCollection).toHaveBeenCalledWith('Space stuff', 'FolderOpen', undefined, 'podcasts')
+ expect(addPodcastsToCollection).toHaveBeenCalledWith(3, [7])
+ })
+
+ it('offers to create one when the user has no podcast collections yet', async () => {
+ fetchPodcastCollectionMembership.mockResolvedValue([])
+ const wrapper = mountSheet()
+ await open(wrapper)
+
+ expect(wrapper.text()).toContain('No podcast collections yet')
+ expect(wrapper.find('input[aria-label="New collection name"]').exists()).toBe(true)
+ })
+
+ it('announces the membership load instead of showing an empty list while it runs', async () => {
+ let resolveMembership: ((rows: Collection[]) => void) | undefined
+ fetchPodcastCollectionMembership.mockReturnValue(
+ new Promise((resolve) => {
+ resolveMembership = resolve
+ }),
+ )
+ const wrapper = mountSheet()
+ await wrapper.setProps({ open: true })
+ await flushPromises()
+
+ expect(wrapper.find('[role="status"]').exists()).toBe(true)
+ expect(wrapper.text()).not.toContain('No podcast collections yet')
+
+ resolveMembership!([makeCollection(1, 'Sci-fi')])
+ await flushPromises()
+
+ expect(wrapper.find('[role="status"]').exists()).toBe(false)
+ })
+
+ it('removes the show when its collection is clicked again, rather than dead-ending on a disabled row', async () => {
+ fetchPodcastCollectionMembership.mockResolvedValue([{ ...makeCollection(1, 'Sci-fi'), memberCount: 1 }])
+ const wrapper = mountSheet()
+ await open(wrapper)
+
+ await collectionRow(wrapper, 'Sci-fi').trigger('click')
+ await flushPromises()
+
+ expect(removePodcastsFromCollection).toHaveBeenCalledWith(1, [7])
+ expect(wrapper.text()).not.toContain('In this collection')
+ })
+
+ it('refreshes the collection list so the sidebar count follows a membership change', async () => {
+ const wrapper = mountSheet()
+ await open(wrapper)
+
+ await collectionRow(wrapper, 'Sci-fi').trigger('click')
+ await flushPromises()
+
+ expect(refreshCollections).toHaveBeenCalled()
+ })
+})
diff --git a/client/src/features/podcast/components/AddShowToCollectionSheet.vue b/client/src/features/podcast/components/AddShowToCollectionSheet.vue
new file mode 100644
index 000000000..5f9d535bb
--- /dev/null
+++ b/client/src/features/podcast/components/AddShowToCollectionSheet.vue
@@ -0,0 +1,198 @@
+
+
+
+
+
+
+
+
+ {{ t('podcast.collections.addTitle') }}
+
+ {{ t('podcast.collections.addDescription', { title: show?.title ?? '' }) }}
+
+
+
+
+
{{ t('podcast.collections.newCollection') }}
+
+
+
+
+
+ {{ t('podcast.collections.create') }}
+
+
+
+
+
+ {{ t('common.loading') }}
+
+
+
+
+
{{ t('podcast.collections.existing') }}
+
+
+
+
+
+
+
+
+ {{ t('podcast.collections.none') }}
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastAcquisitionField.spec.ts b/client/src/features/podcast/components/PodcastAcquisitionField.spec.ts
new file mode 100644
index 000000000..644ac4f1f
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastAcquisitionField.spec.ts
@@ -0,0 +1,34 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import PodcastAcquisitionField from './PodcastAcquisitionField.vue'
+
+describe('PodcastAcquisitionField', () => {
+ it('presents only distinct automatic download policies', () => {
+ const wrapper = mountField('remote_only')
+
+ expect(wrapper.findAll('option').map((option) => option.text())).toEqual(['Never', 'New episodes', 'Recent episodes'])
+ expect(wrapper.text()).toContain('You can still download individual episodes')
+ expect(wrapper.find('option[value="manual"]').exists()).toBe(false)
+ })
+
+ it('explains publication-date and storage-retention behavior', () => {
+ const wrapper = mountField('window')
+
+ expect(wrapper.text()).toContain('Episodes without a publication date are skipped')
+ expect(wrapper.text()).toContain('storage retention can remove unpinned downloads')
+ expect(wrapper.get('input').attributes()).toMatchObject({ min: '1', max: '3650', required: '' })
+ })
+})
+
+function mountField(policy: 'remote_only' | 'window') {
+ return mount(PodcastAcquisitionField, {
+ props: {
+ policy,
+ limit: 3,
+ windowDays: 30,
+ 'onUpdate:policy': () => undefined,
+ 'onUpdate:limit': () => undefined,
+ 'onUpdate:windowDays': () => undefined,
+ },
+ })
+}
diff --git a/client/src/features/podcast/components/PodcastAcquisitionField.vue b/client/src/features/podcast/components/PodcastAcquisitionField.vue
new file mode 100644
index 000000000..6a58f19cd
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastAcquisitionField.vue
@@ -0,0 +1,42 @@
+
+
+
+
+ {{ t('podcast.acquisition.title') }}
+
+ {{ t('podcast.acquisition.never') }}
+ {{ t('podcast.acquisition.newEpisodes') }}
+ {{ t('podcast.acquisition.recentEpisodes') }}
+
+ {{ description }}
+
+ {{ t('podcast.acquisition.maximumNewest') }}
+
+
+
+ {{ t('podcast.acquisition.publishedWithinDays') }}
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastAddFeedSheet.spec.ts b/client/src/features/podcast/components/PodcastAddFeedSheet.spec.ts
new file mode 100644
index 000000000..6862f1931
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastAddFeedSheet.spec.ts
@@ -0,0 +1,83 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastDirectoryResult } from '@bookorbit/types'
+import { api } from '@/lib/api'
+import { jsonResponse } from '../test/fixtures'
+import { sheetStubs } from '../test/stubs'
+import PodcastAddFeedSheet from './PodcastAddFeedSheet.vue'
+
+vi.mock('@/lib/api', () => ({
+ api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(async () => new Response(null, { status: 204 })),
+}))
+vi.mock('vue-sonner', () => ({ toast: { success: vi.fn<() => void>(), error: vi.fn<() => void>() } }))
+
+const apiMock = vi.mocked(api)
+
+function directoryResult(overrides: Partial = {}): PodcastDirectoryResult {
+ return {
+ title: 'Orbit Radio',
+ author: 'Orbit',
+ feedUrl: 'https://a.example/feed.xml',
+ artworkUrl: null,
+ genre: null,
+ existingPodcastId: null,
+ existingLibraryId: null,
+ ...overrides,
+ }
+}
+
+function mountSheet() {
+ return mount(PodcastAddFeedSheet, {
+ props: { open: true, libraryId: 7 },
+ global: { stubs: sheetStubs() },
+ })
+}
+
+describe('PodcastAddFeedSheet', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ apiMock.mockReset()
+ apiMock.mockResolvedValue(new Response(null, { status: 204 }))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('flags a directory result already in the library and opens it instead of previewing the feed', async () => {
+ apiMock.mockImplementation(async (input) =>
+ String(input).includes('/podcast-search')
+ ? jsonResponse([
+ directoryResult({ existingPodcastId: 3, existingLibraryId: 7 }),
+ directoryResult({ title: 'New Show', feedUrl: 'https://b.example/feed.xml' }),
+ ])
+ : new Response(null, { status: 204 }),
+ )
+ const wrapper = mountSheet()
+
+ await wrapper.get('#podcast-directory-search').setValue('orbit')
+ await vi.advanceTimersByTimeAsync(300)
+ await flushPromises()
+
+ const badges = wrapper.findAll('[data-testid="podcast-directory-in-library"]')
+ expect(badges).toHaveLength(1)
+ expect(badges[0]!.text()).toBe('In library')
+
+ await wrapper.get('button[aria-label="Open Orbit Radio, already in your library"]').trigger('click')
+
+ expect(wrapper.emitted('open-show')).toEqual([[3]])
+ // Selecting an existing show hands off to the view rather than opening a preview request for it.
+ expect(apiMock).not.toHaveBeenCalledWith(expect.stringContaining('/feed-preview'), expect.anything())
+ })
+
+ it('offers the OPML and local-file entry points as sheet handoffs', async () => {
+ const wrapper = mountSheet()
+
+ await wrapper.get('[data-testid="podcast-open-opml-import"]').trigger('click')
+ expect(wrapper.emitted('import-opml')).toHaveLength(1)
+ expect(wrapper.emitted('update:open')?.at(-1)).toEqual([false])
+
+ await wrapper.get('[data-testid="podcast-open-local-import"]').trigger('click')
+ expect(wrapper.emitted('import-local')).toHaveLength(1)
+ })
+})
diff --git a/client/src/features/podcast/components/PodcastAddFeedSheet.vue b/client/src/features/podcast/components/PodcastAddFeedSheet.vue
new file mode 100644
index 000000000..61139b8bf
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastAddFeedSheet.vue
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+
+ {{ t('podcast.library.importOpml') }}
+
+
+ {{ t('podcast.import.title') }}
+
+
+
+
{{ t('podcast.library.searchDirectory') }}
+
+
{{ t('podcast.library.searchDirectoryHint') }}
+
{{ directoryError }}
+
+
+
+
+
+ {{ result.title }}
+ {{ result.author || t('podcast.labels.unknownPublisher') }}
+
+
+ {{ t('podcast.library.inLibrary') }}
+
+
+
+
+
{{ t('podcast.library.searchDirectoryEmpty') }}
+
+
{{ t('podcast.library.pasteFeedUrl') }}
+
+
+
+ {{ previewing ? t('podcast.library.previewing') : t('podcast.library.preview') }}
+
+
+
{{ previewError }}
+
+
+
+
+
{{ preview.title }}
+
+ {{ preview.author || t('podcast.labels.unknownPublisher') }} ·
+ {{ t('podcast.labels.episodeCount', { count: preview.episodeCount }) }}
+
+
{{ previewMetadata }}
+
+
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastArtwork.vue b/client/src/features/podcast/components/PodcastArtwork.vue
new file mode 100644
index 000000000..d8de96dd0
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastArtwork.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastBookmarksPanel.spec.ts b/client/src/features/podcast/components/PodcastBookmarksPanel.spec.ts
new file mode 100644
index 000000000..4e9bc4cff
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastBookmarksPanel.spec.ts
@@ -0,0 +1,34 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import PodcastBookmarksPanel from './PodcastBookmarksPanel.vue'
+import { usePodcastShortcuts } from '../composables/usePodcastShortcuts'
+
+const player = vi.hoisted(() => ({
+ bookmarks: { value: [] },
+ bookmarkTitle: { value: '' },
+ bookmarkNote: { value: '' },
+ currentTime: { value: 42 },
+ createBookmark: vi.fn<() => Promise>(async () => undefined),
+ seekTo: vi.fn<(seconds: number) => void>(),
+ updateBookmark: vi.fn<() => Promise>(async () => undefined),
+ deleteBookmark: vi.fn<() => Promise>(async () => undefined),
+}))
+
+vi.mock('../composables/usePodcastPlayer', () => ({ usePodcastPlayer: () => player }))
+
+describe('PodcastBookmarksPanel', () => {
+ afterEach(() => {
+ document.body.innerHTML = ''
+ })
+
+ it('opens and focuses the bookmark title when requested by a shortcut', async () => {
+ const wrapper = mount(PodcastBookmarksPanel, { attachTo: document.body })
+
+ usePodcastShortcuts().requestBookmarkComposer()
+ await flushPromises()
+
+ const title = wrapper.get('input[aria-label="Bookmark title"]').element
+ expect(document.activeElement).toBe(title)
+ wrapper.unmount()
+ })
+})
diff --git a/client/src/features/podcast/components/PodcastBookmarksPanel.vue b/client/src/features/podcast/components/PodcastBookmarksPanel.vue
new file mode 100644
index 000000000..3128e7f6b
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastBookmarksPanel.vue
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('podcast.actions.cancel') }}
+
+ {{ t('podcast.actions.save') }}
+
+
+
+
+
+ {{ bookmark.title }}
+ {{ formatPlaybackClock(bookmark.positionSeconds) }}
+ {{
+ bookmark.note
+ }}
+
+
+
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastDownloadWidget.spec.ts b/client/src/features/podcast/components/PodcastDownloadWidget.spec.ts
new file mode 100644
index 000000000..cdbdc57c3
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastDownloadWidget.spec.ts
@@ -0,0 +1,90 @@
+import { ref } from 'vue'
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastDownloadBatch } from '@bookorbit/types'
+import PodcastDownloadWidget from './PodcastDownloadWidget.vue'
+
+const mockUsePodcastDownloadBatches = vi.hoisted(() => vi.fn<() => unknown>())
+const batches = ref([])
+const start = vi.fn<() => void>()
+const stop = vi.fn<() => void>()
+const dismiss = vi.fn<(batchId: string) => void>()
+
+vi.mock('../composables/usePodcastDownloadBatches', () => ({
+ usePodcastDownloadBatches: mockUsePodcastDownloadBatches,
+}))
+
+describe('PodcastDownloadWidget', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ batches.value = []
+ mockUsePodcastDownloadBatches.mockReturnValue({ batches, loading: ref(false), start, stop, dismiss })
+ })
+
+ it('stays absent without bulk work and owns the global event lifecycle', () => {
+ const wrapper = mount(PodcastDownloadWidget)
+
+ expect(wrapper.find('[data-testid="podcast-download-widget"]').exists()).toBe(false)
+ expect(start).toHaveBeenCalledOnce()
+
+ wrapper.unmount()
+ expect(stop).toHaveBeenCalledOnce()
+ })
+
+ it('shows aggregate progress and expands into bounded per-episode detail', async () => {
+ batches.value = [batch()]
+ const wrapper = mount(PodcastDownloadWidget)
+
+ expect(wrapper.text()).toContain('1 of 2 downloaded')
+ expect(wrapper.find('[data-testid="download-batch-list"]').exists()).toBe(false)
+ expect(wrapper.get('[role="progressbar"]').attributes('aria-valuenow')).toBe('50')
+
+ await wrapper.get('button[aria-label="Expand download progress"]').trigger('click')
+
+ expect(wrapper.get('[data-testid="download-batch-list"]').text()).toContain('Orbit Radio')
+ expect(wrapper.text()).toContain('Launch')
+ expect(wrapper.text()).toContain('Landing')
+ const episodeProgress = wrapper.get('[role="progressbar"][aria-label="Download progress for Landing"]')
+ expect(episodeProgress.attributes('aria-valuenow')).toBeUndefined()
+ })
+
+ it('keeps failures visible and offers a translated accessible dismiss action', async () => {
+ const failed = batch()
+ failed.items[1]!.status = 'failed'
+ failed.downloading = 0
+ failed.failed = 1
+ batches.value = [failed]
+ const wrapper = mount(PodcastDownloadWidget)
+
+ expect(wrapper.text()).toContain('1 of 2 downloaded; 1 failed')
+ await wrapper.get('button[aria-label="Expand download progress"]').trigger('click')
+ await wrapper.get('button[aria-label="Dismiss download progress"]').trigger('click')
+
+ expect(dismiss).toHaveBeenCalledWith(BATCH_ID)
+ })
+})
+
+const BATCH_ID = '0f31ea6f-03f0-4e3d-ae5c-1467ae7ec115'
+
+function batch(): PodcastDownloadBatch {
+ return {
+ id: BATCH_ID,
+ libraryId: 7,
+ podcastId: 3,
+ podcastTitle: 'Orbit Radio',
+ total: 2,
+ queued: 0,
+ downloading: 1,
+ completed: 1,
+ failed: 0,
+ cancelled: 0,
+ receivedBytes: 125,
+ totalBytes: null,
+ createdAt: '2026-08-01T12:00:00.000Z',
+ updatedAt: '2026-08-01T12:02:00.000Z',
+ items: [
+ { episodeId: 42, title: 'Launch', status: 'completed', receivedBytes: 100, totalBytes: 100 },
+ { episodeId: 43, title: 'Landing', status: 'downloading', receivedBytes: 25, totalBytes: null },
+ ],
+ }
+}
diff --git a/client/src/features/podcast/components/PodcastDownloadWidget.vue b/client/src/features/podcast/components/PodcastDownloadWidget.vue
new file mode 100644
index 000000000..a4c2abe58
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastDownloadWidget.vue
@@ -0,0 +1,249 @@
+
+
+
+
+
+ {{ summary }}
+
+
+
+
+
+ {{ t('podcast.downloadWidget.title') }}
+
+ {{ summary }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastEditDetailsSheet.spec.ts b/client/src/features/podcast/components/PodcastEditDetailsSheet.spec.ts
new file mode 100644
index 000000000..5374ceb96
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastEditDetailsSheet.spec.ts
@@ -0,0 +1,224 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { defineComponent, h } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastSummary } from '@bookorbit/types'
+import { api } from '@/lib/api'
+import PodcastEditDetailsSheet from './PodcastEditDetailsSheet.vue'
+import { jsonResponse } from '../test/fixtures'
+
+vi.mock('@/lib/api', () => ({ api: vi.fn<(url: string, init?: RequestInit) => Promise>() }))
+vi.mock('vue-sonner', () => ({ toast: { success: vi.fn<() => void>(), error: vi.fn<() => void>() } }))
+
+/** The shared sheet teleports out of the wrapper, so the panel is rendered in place for these assertions. */
+vi.mock('@/components/ui/sheet', () => {
+ const passthrough = (tag: string) =>
+ defineComponent({
+ setup:
+ (_props, { slots }) =>
+ () =>
+ h(tag, slots.default?.()),
+ }) as ReturnType
+ return {
+ Sheet: passthrough('div'),
+ SheetContent: passthrough('div'),
+ SheetHeader: passthrough('div'),
+ SheetTitle: passthrough('h2'),
+ SheetDescription: passthrough('p'),
+ SheetFooter: passthrough('div'),
+ }
+})
+
+const apiMock = vi.mocked(api)
+
+function show(overrides: Partial = {}): PodcastSummary {
+ return {
+ id: 12,
+ libraryId: 7,
+ origin: 'feed',
+ title: 'Orbit Radio',
+ author: 'Orbit Media',
+ description: null,
+ imageUrl: '/api/v1/podcasts/12/artwork',
+ siteUrl: null,
+ language: null,
+ podcastType: null,
+ explicit: false,
+ categories: ['Science'],
+ acquisitionPolicy: 'remote_only',
+ autoDownloadLimit: null,
+ autoDownloadWindowDays: null,
+ downloadCleanup: 'keep',
+ downloadCleanupDelayHours: 24,
+ refreshIntervalMinutes: 60,
+ lockedFields: [],
+ artworkUpdatedAt: null,
+ episodeCount: 4,
+ unplayedCount: 4,
+ downloadedCount: 0,
+ playbackRecommendation: null,
+ followed: false,
+ notificationMode: 'off',
+ archivedAt: null,
+ missingAt: null,
+ lastRefreshAt: null,
+ lastRefreshSuccessAt: null,
+ feedSnapshotAt: null,
+ consecutiveFailures: 0,
+ nextRefreshAt: '2026-07-29T00:00:00.000Z',
+ createdAt: '2026-07-01T00:00:00.000Z',
+ updatedAt: '2026-07-01T00:00:00.000Z',
+ ...overrides,
+ }
+}
+
+async function mountSheet(summary: PodcastSummary = show()) {
+ const wrapper = mount(PodcastEditDetailsSheet, { props: { open: true, show: summary } })
+ await flushPromises()
+ return wrapper
+}
+
+function buttonByText(wrapper: Awaited>, text: string) {
+ return wrapper.findAll('button').find((button) => button.text() === text)
+}
+
+describe('PodcastEditDetailsSheet', () => {
+ beforeEach(() => {
+ apiMock.mockReset()
+ })
+
+ it('loads the show values into the form', async () => {
+ const wrapper = await mountSheet(show({ siteUrl: 'https://orbit.example', language: 'en', explicit: true }))
+
+ expect((wrapper.get('#podcast-edit-title').element as HTMLInputElement).value).toBe('Orbit Radio')
+ expect((wrapper.get('#podcast-edit-author').element as HTMLInputElement).value).toBe('Orbit Media')
+ expect((wrapper.get('#podcast-edit-site-url').element as HTMLInputElement).value).toBe('https://orbit.example')
+ expect(wrapper.get('[role="switch"]').attributes('aria-checked')).toBe('true')
+ expect(wrapper.text()).toContain('Science')
+ })
+
+ it('sends only the edited fields and emits the saved result', async () => {
+ apiMock.mockResolvedValue(
+ jsonResponse({
+ id: 12,
+ title: 'Orbit Radio',
+ author: 'Orbit Studios',
+ description: null,
+ siteUrl: null,
+ language: null,
+ explicit: false,
+ categories: ['Science'],
+ lockedFields: ['author'],
+ }),
+ )
+ const wrapper = await mountSheet()
+
+ await wrapper.get('#podcast-edit-author').setValue('Orbit Studios')
+ await wrapper.get('[data-testid="podcast-save-details"]').trigger('click')
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledWith('/api/v1/podcasts/12/metadata', expect.objectContaining({ method: 'PATCH' }))
+ expect(JSON.parse(String(apiMock.mock.calls[0]?.[1]?.body))).toEqual({ author: 'Orbit Studios' })
+ expect(wrapper.emitted('saved')?.[0]?.[0]).toMatchObject({ lockedFields: ['author'] })
+ expect(wrapper.emitted('update:open')?.[0]).toEqual([false])
+ })
+
+ it('shows an edited field as pending a lock before it is saved', async () => {
+ const wrapper = await mountSheet()
+ const lockButton = () => wrapper.get('[aria-label="Lock Publisher against feed refreshes"]')
+
+ expect(lockButton().attributes('aria-pressed')).toBe('false')
+
+ await wrapper.get('#podcast-edit-author').setValue('Orbit Studios')
+
+ expect(wrapper.get('[aria-label="Publisher will be locked when you save"]').attributes('aria-pressed')).toBe('false')
+ expect(wrapper.text()).toContain('This field will be locked when you save.')
+ })
+
+ it('sends the reduced lock set when a locked field is unlocked', async () => {
+ apiMock.mockResolvedValue(
+ jsonResponse({
+ id: 12,
+ title: 'Orbit Radio',
+ author: 'Orbit Media',
+ description: null,
+ siteUrl: null,
+ language: null,
+ explicit: false,
+ categories: ['Science'],
+ lockedFields: [],
+ }),
+ )
+ const wrapper = await mountSheet(show({ lockedFields: ['author'] }))
+
+ await wrapper.get('[aria-label="Unlock Publisher so the feed can update it"]').trigger('click')
+ await wrapper.get('[data-testid="podcast-save-details"]').trigger('click')
+ await flushPromises()
+
+ expect(JSON.parse(String(apiMock.mock.calls[0]?.[1]?.body))).toEqual({ lockedFields: [] })
+ })
+
+ it('adds a category with the keyboard and removes it again', async () => {
+ const wrapper = await mountSheet()
+
+ await wrapper.get('#podcast-edit-categories').setValue('Technology')
+ await wrapper.get('#podcast-edit-categories').trigger('keydown', { key: 'Enter' })
+ expect(wrapper.text()).toContain('Technology')
+
+ await wrapper.get('[aria-label="Remove category Technology"]').trigger('click')
+ expect(wrapper.text()).not.toContain('Technology')
+ })
+
+ it('uploads a chosen artwork file and emits the new versioned url', async () => {
+ apiMock.mockResolvedValue(jsonResponse({ id: 12, imageUrl: '/api/v1/podcasts/12/artwork?v=99', artworkUpdatedAt: '2026-07-31T00:00:00.000Z' }))
+ const wrapper = await mountSheet()
+ const input = wrapper.get('input[type="file"]')
+ const file = new File(['bytes'], 'artwork.png', { type: 'image/png' })
+ Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
+
+ await input.trigger('change')
+ await wrapper.get('[data-testid="podcast-confirm-artwork"]').trigger('click')
+ await flushPromises()
+
+ const [url, init] = apiMock.mock.calls[0] ?? []
+ expect(url).toBe('/api/v1/podcasts/12/artwork')
+ expect(init?.method).toBe('POST')
+ expect(init?.body).toBeInstanceOf(FormData)
+ expect(wrapper.emitted('artwork-changed')?.[0]?.[0]).toMatchObject({ artworkUpdatedAt: '2026-07-31T00:00:00.000Z' })
+ })
+
+ it('uploads artwork from a url without touching the metadata endpoint', async () => {
+ apiMock.mockResolvedValue(jsonResponse({ id: 12, imageUrl: '/api/v1/podcasts/12/artwork?v=99', artworkUpdatedAt: '2026-07-31T00:00:00.000Z' }))
+ const wrapper = await mountSheet()
+
+ await wrapper.get('#podcast-edit-artwork-url').setValue('https://cdn.example/art.png')
+ await wrapper.get('[data-testid="podcast-confirm-artwork"]').trigger('click')
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledWith('/api/v1/podcasts/12/artwork/from-url', expect.objectContaining({ method: 'POST' }))
+ expect(JSON.parse(String(apiMock.mock.calls[0]?.[1]?.body))).toEqual({ url: 'https://cdn.example/art.png' })
+ })
+
+ it('offers removal only while custom artwork is stored', async () => {
+ apiMock.mockResolvedValue(jsonResponse({ id: 12, imageUrl: '/api/v1/podcasts/12/artwork', artworkUpdatedAt: null }))
+ const withoutCustom = await mountSheet()
+ expect(withoutCustom.find('[data-testid="podcast-remove-custom-artwork"]').exists()).toBe(false)
+
+ const wrapper = await mountSheet(show({ artworkUpdatedAt: '2026-07-31T00:00:00.000Z' }))
+ await wrapper.get('[data-testid="podcast-remove-custom-artwork"]').trigger('click')
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledWith('/api/v1/podcasts/12/artwork', { method: 'DELETE' })
+ expect(wrapper.emitted('artwork-changed')?.[0]?.[0]).toMatchObject({ artworkUpdatedAt: null })
+ })
+
+ it('closes without a request when nothing changed', async () => {
+ const wrapper = await mountSheet()
+
+ await wrapper.get('[data-testid="podcast-save-details"]').trigger('click')
+ await flushPromises()
+
+ expect(apiMock).not.toHaveBeenCalled()
+ expect(wrapper.emitted('update:open')?.[0]).toEqual([false])
+ expect(buttonByText(wrapper, 'Save')).toBeTruthy()
+ })
+})
diff --git a/client/src/features/podcast/components/PodcastEditDetailsSheet.vue b/client/src/features/podcast/components/PodcastEditDetailsSheet.vue
new file mode 100644
index 000000000..c24de0d68
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastEditDetailsSheet.vue
@@ -0,0 +1,339 @@
+
+
+
+
+
+
+ {{ t('podcast.edit.artworkTitle') }}
+
+
+
+
{{ t('podcast.edit.artworkHint') }}
+
+
+ {{ t('podcast.edit.chooseImage') }}
+
+
+ {{ t('podcast.edit.removeCustomArtwork') }}
+
+
+
+
+ {{ t('podcast.edit.artworkUrlLabel') }}
+
+
+
+
+
+
+
+ {{ t('podcast.edit.useImage') }}
+
+
+ {{ t('podcast.actions.cancel') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('podcast.edit.descriptionHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ category }}
+
+
+
+
+
+
+ {{ t('podcast.actions.add') }}
+
+
+ {{ t('podcast.edit.categoryLimit', { count: PODCAST_MAX_CATEGORIES }) }}
+
+
+
+
+
+ {{ t('podcast.edit.explicitHint') }}
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastEpisodeEditSheet.spec.ts b/client/src/features/podcast/components/PodcastEpisodeEditSheet.spec.ts
new file mode 100644
index 000000000..3063cc013
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastEpisodeEditSheet.spec.ts
@@ -0,0 +1,180 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { defineComponent, h } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastEpisodeSummary } from '@bookorbit/types'
+import { api } from '@/lib/api'
+import PodcastEpisodeEditSheet from './PodcastEpisodeEditSheet.vue'
+import { jsonResponse } from '../test/fixtures'
+
+vi.mock('@/lib/api', () => ({ api: vi.fn<(url: string, init?: RequestInit) => Promise>() }))
+vi.mock('vue-sonner', () => ({ toast: { success: vi.fn<() => void>(), error: vi.fn<() => void>() } }))
+
+/** The shared sheet teleports out of the wrapper, so the panel is rendered in place for these assertions. */
+vi.mock('@/components/ui/sheet', () => {
+ const passthrough = (tag: string) =>
+ defineComponent({
+ setup:
+ (_props, { slots }) =>
+ () =>
+ h(tag, slots.default?.()),
+ }) as ReturnType
+ return {
+ Sheet: passthrough('div'),
+ SheetContent: passthrough('div'),
+ SheetHeader: passthrough('div'),
+ SheetTitle: passthrough('h2'),
+ SheetDescription: passthrough('p'),
+ SheetFooter: passthrough('div'),
+ }
+})
+
+const apiMock = vi.mocked(api)
+
+function episode(overrides: Partial = {}): PodcastEpisodeSummary {
+ return {
+ id: 42,
+ libraryId: 7,
+ origin: 'feed',
+ podcastId: 12,
+ podcastTitle: 'Orbit Radio',
+ podcastImageUrl: null,
+ title: 'Orbital mechanics',
+ subtitle: 'Part one',
+ description: null,
+ publishedAt: null,
+ season: '2',
+ episode: '14',
+ episodeType: 'full',
+ durationSeconds: 1800,
+ audioFormat: 'mp3',
+ explicit: false,
+ chapters: [],
+ transcripts: [],
+ lockedFields: [],
+ inFeed: true,
+ mediaStatus: 'remote',
+ localSizeBytes: null,
+ checksum: null,
+ positionSeconds: 0,
+ progressPercent: 0,
+ finished: false,
+ pinned: false,
+ queued: false,
+ lastListenedAt: null,
+ createdAt: '2026-07-01T00:00:00.000Z',
+ updatedAt: '2026-07-01T00:00:00.000Z',
+ ...overrides,
+ }
+}
+
+async function mountSheet(loaded: PodcastEpisodeSummary = episode()) {
+ apiMock.mockResolvedValueOnce(jsonResponse(loaded))
+ const wrapper = mount(PodcastEpisodeEditSheet, { props: { open: true, episodeId: loaded.id } })
+ await flushPromises()
+ return wrapper
+}
+
+describe('PodcastEpisodeEditSheet', () => {
+ beforeEach(() => {
+ apiMock.mockReset()
+ })
+
+ it('loads the episode from the server and fills the form', async () => {
+ const wrapper = await mountSheet(episode({ explicit: true }))
+
+ expect(apiMock).toHaveBeenCalledWith('/api/v1/podcast-episodes/42', undefined)
+ expect((wrapper.get('#podcast-episode-edit-title').element as HTMLInputElement).value).toBe('Orbital mechanics')
+ expect((wrapper.get('#podcast-episode-edit-subtitle').element as HTMLInputElement).value).toBe('Part one')
+ expect((wrapper.get('#podcast-episode-edit-season').element as HTMLInputElement).value).toBe('2')
+ expect((wrapper.get('#podcast-episode-edit-duration').element as HTMLInputElement).value).toBe('30')
+ expect((wrapper.get('#podcast-episode-edit-episode-type').element as HTMLSelectElement).value).toBe('full')
+ expect(wrapper.get('[role="switch"]').attributes('aria-checked')).toBe('true')
+ })
+
+ it('sends only the edited fields and emits the saved episode', async () => {
+ const wrapper = await mountSheet()
+ apiMock.mockResolvedValueOnce(jsonResponse(episode({ subtitle: 'Part two', lockedFields: ['subtitle'] })))
+
+ await wrapper.get('#podcast-episode-edit-subtitle').setValue('Part two')
+ await wrapper.get('[data-testid="podcast-save-episode-details"]').trigger('click')
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenLastCalledWith('/api/v1/podcast-episodes/42/metadata', expect.objectContaining({ method: 'PATCH' }))
+ expect(JSON.parse(String(apiMock.mock.calls[1]?.[1]?.body))).toEqual({ subtitle: 'Part two' })
+ expect(wrapper.emitted('saved')?.[0]?.[0]).toMatchObject({ lockedFields: ['subtitle'] })
+ expect(wrapper.emitted('update:open')?.[0]).toEqual([false])
+ })
+
+ it('shows an edited field as pending a lock before it is saved', async () => {
+ const wrapper = await mountSheet()
+
+ expect(wrapper.get('[aria-label="Lock Subtitle against feed refreshes"]').attributes('aria-pressed')).toBe('false')
+
+ await wrapper.get('#podcast-episode-edit-subtitle').setValue('Part two')
+
+ expect(wrapper.get('[aria-label="Subtitle will be locked when you save"]').attributes('aria-pressed')).toBe('false')
+ expect(wrapper.text()).toContain('This field will be locked when you save.')
+ })
+
+ it('sends the reduced lock set when a locked field is unlocked', async () => {
+ const wrapper = await mountSheet(episode({ lockedFields: ['season'] }))
+ apiMock.mockResolvedValueOnce(jsonResponse(episode()))
+
+ await wrapper.get('[aria-label="Unlock Season so the feed can update it"]').trigger('click')
+ await wrapper.get('[data-testid="podcast-save-episode-details"]').trigger('click')
+ await flushPromises()
+
+ expect(JSON.parse(String(apiMock.mock.calls[1]?.[1]?.body))).toEqual({ lockedFields: [] })
+ })
+
+ it('converts the duration back to seconds on save', async () => {
+ const wrapper = await mountSheet()
+ apiMock.mockResolvedValueOnce(jsonResponse(episode({ durationSeconds: 2700 })))
+
+ await wrapper.get('#podcast-episode-edit-duration').setValue('45')
+ await wrapper.get('[data-testid="podcast-save-episode-details"]').trigger('click')
+ await flushPromises()
+
+ expect(JSON.parse(String(apiMock.mock.calls[1]?.[1]?.body))).toEqual({ durationSeconds: 2700 })
+ })
+
+ it('refuses to save a duration that is not a positive number', async () => {
+ const wrapper = await mountSheet()
+
+ await wrapper.get('#podcast-episode-edit-duration').setValue('0')
+ await wrapper.get('[data-testid="podcast-save-episode-details"]').trigger('click')
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledTimes(1)
+ expect(wrapper.get('#podcast-episode-edit-duration-error').text()).toContain('greater than zero')
+ })
+
+ it('keeps a feed episode type the iTunes set does not cover as a selectable option', async () => {
+ const wrapper = await mountSheet(episode({ episodeType: 'Full Episode' }))
+ const select = wrapper.get('#podcast-episode-edit-episode-type')
+
+ expect((select.element as HTMLSelectElement).value).toBe('Full Episode')
+ expect(select.text()).toContain('Full Episode (from the feed)')
+ })
+
+ it('closes without a write when nothing changed', async () => {
+ const wrapper = await mountSheet()
+
+ await wrapper.get('[data-testid="podcast-save-episode-details"]').trigger('click')
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted('update:open')?.[0]).toEqual([false])
+ })
+
+ // The server's English `message` never reaches the interface; the localized fallback does.
+ it('offers a retry when the episode cannot be loaded', async () => {
+ apiMock.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Podcast episode not found' }), { status: 404 }))
+ const wrapper = mount(PodcastEpisodeEditSheet, { props: { open: true, episodeId: 42 } })
+ await flushPromises()
+
+ expect(wrapper.get('[role="alert"]').text()).toContain('Failed to load podcast episode')
+ expect(wrapper.find('#podcast-episode-edit-title').exists()).toBe(false)
+ expect((wrapper.get('[data-testid="podcast-save-episode-details"]').element as HTMLButtonElement).disabled).toBe(true)
+ })
+})
diff --git a/client/src/features/podcast/components/PodcastEpisodeEditSheet.vue b/client/src/features/podcast/components/PodcastEpisodeEditSheet.vue
new file mode 100644
index 000000000..7920afe55
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastEpisodeEditSheet.vue
@@ -0,0 +1,283 @@
+
+
+
+
+
+ {{ t('podcast.fullPlayer.loading') }}
+
+
+
+
+
+
+
+
+
+
+ {{ isLocalEpisode ? t('podcast.episodeEdit.localHint') : t('podcast.episodeEdit.lockHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('podcast.episodeEdit.descriptionHint') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('podcast.episodeEdit.episodeTypeNone') }}
+ {{ t('podcast.episodeEdit.episodeTypeFull') }}
+ {{ t('podcast.episodeEdit.episodeTypeTrailer') }}
+ {{ t('podcast.episodeEdit.episodeTypeBonus') }}
+
+ {{ t('podcast.episodeEdit.episodeTypeFromFeed', { value: foreignEpisodeType }) }}
+
+
+
+
+
+
+
+
+ {{ t('podcast.episodeEdit.durationInvalid') }}
+
+
+ {{ t('podcast.episodeEdit.durationHint') }}
+
+
+
+
+
+
+
+ {{ t('podcast.episodeEdit.explicitHint') }}
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastEpisodeQuickView.spec.ts b/client/src/features/podcast/components/PodcastEpisodeQuickView.spec.ts
new file mode 100644
index 000000000..9a0b1d9b5
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastEpisodeQuickView.spec.ts
@@ -0,0 +1,155 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { defineComponent, h } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastEpisodeListItem, PodcastEpisodeSummary } from '@bookorbit/types'
+import { api } from '@/lib/api'
+import PodcastEpisodeQuickView from './PodcastEpisodeQuickView.vue'
+import { createPodcastEpisodeActionsStub, provideEpisodeActions } from '../test/episode-action-stubs'
+import { jsonResponse } from '../test/fixtures'
+
+vi.mock('@/lib/api', () => ({ api: vi.fn<(url: string) => Promise>() }))
+
+vi.mock('@/components/ui/sheet', () => {
+ const passthrough = (tag: string) =>
+ defineComponent({
+ setup:
+ (_props, { slots }) =>
+ () =>
+ h(tag, slots.default?.()),
+ }) as ReturnType
+ return {
+ Sheet: passthrough('div'),
+ SheetContent: passthrough('div'),
+ SheetHeader: passthrough('div'),
+ SheetTitle: passthrough('h2'),
+ SheetDescription: passthrough('p'),
+ }
+})
+
+const apiMock = vi.mocked(api)
+
+describe('PodcastEpisodeQuickView', () => {
+ beforeEach(() => {
+ apiMock.mockReset()
+ apiMock.mockResolvedValue(jsonResponse(createDetail()))
+ })
+
+ it('fetches and renders the full episode detail when opened', async () => {
+ const wrapper = mountQuickView()
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledWith('/api/v1/podcast-episodes/665', undefined)
+ expect(wrapper.text()).toContain('The complete episode description.')
+ expect(wrapper.text()).toContain('Chapters 2')
+ expect(wrapper.get('a[href="https://cdn.example.com/transcript.vtt"]').text()).toContain('en')
+ })
+
+ it('drives the same episode actions the row does', async () => {
+ const actions = createPodcastEpisodeActionsStub()
+ const wrapper = mountQuickView({}, actions)
+ await flushPromises()
+
+ await findButton(wrapper, 'Listen').trigger('click')
+ await findButton(wrapper, 'Remove from queue').trigger('click')
+ await findButton(wrapper, 'Remove download').trigger('click')
+ await findButton(wrapper, 'Mark played').trigger('click')
+ await findButton(wrapper, 'Open full player').trigger('click')
+
+ expect(actions.play).toHaveBeenCalledWith(expect.objectContaining({ id: 665 }))
+ expect(actions.unqueue).toHaveBeenCalledWith(expect.objectContaining({ id: 665 }))
+ expect(actions.removeDownload).toHaveBeenCalledWith(expect.objectContaining({ id: 665 }))
+ expect(actions.finish).toHaveBeenCalledWith(expect.objectContaining({ id: 665 }))
+ expect(actions.openPlayer).toHaveBeenCalledWith(expect.objectContaining({ id: 665 }))
+ expect(wrapper.emitted('update:open')).toContainEqual([false])
+ })
+
+ it('offers metadata editing only to a caller that may edit it', async () => {
+ // A Response body reads once, so each mount needs its own.
+ apiMock.mockImplementation(() => Promise.resolve(jsonResponse(createDetail())))
+ const withoutPermission = mountQuickView()
+ await flushPromises()
+ expect(withoutPermission.find('[data-testid="podcast-quick-view-edit-metadata"]').exists()).toBe(false)
+
+ const actions = createPodcastEpisodeActionsStub()
+ const wrapper = mountQuickView({ canEditMetadata: true }, actions)
+ await flushPromises()
+ await wrapper.get('[data-testid="podcast-quick-view-edit-metadata"]').trigger('click')
+
+ expect(actions.openMetadataEditor).toHaveBeenCalledWith(expect.objectContaining({ id: 665 }))
+ expect(wrapper.emitted('update:open')).toContainEqual([false])
+ })
+
+ // The server's English `message` never reaches the interface; the localized fallback does.
+ it('shows a localized error and retries the detail request', async () => {
+ apiMock.mockResolvedValueOnce(jsonResponse({ message: 'Could not load details' }, 500)).mockResolvedValueOnce(jsonResponse(createDetail()))
+ const wrapper = mountQuickView()
+ await flushPromises()
+
+ expect(wrapper.get('[role="alert"]').text()).toContain('Failed to load podcast episode')
+ await findButton(wrapper, 'Retry').trigger('click')
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledTimes(2)
+ expect(wrapper.text()).toContain('The complete episode description.')
+ })
+})
+
+function mountQuickView(overrides: { canEditMetadata?: boolean } = {}, actions = createPodcastEpisodeActionsStub()) {
+ return mount(PodcastEpisodeQuickView, {
+ props: { episode: createEpisode(), open: true, canDownload: true, ...overrides },
+ global: { provide: provideEpisodeActions(actions) },
+ })
+}
+
+function findButton(wrapper: ReturnType, text: string) {
+ const button = wrapper.findAll('button').find((candidate) => candidate.text().includes(text))
+ if (!button) throw new Error(`Missing button: ${text}`)
+ return button
+}
+
+function createEpisode(): PodcastEpisodeListItem {
+ return {
+ id: 665,
+ libraryId: 2,
+ origin: 'feed',
+ podcastId: 12,
+ podcastTitle: 'The Long Orbit',
+ podcastImageUrl: '/api/v1/podcasts/12/artwork',
+ title: 'A Better Episode Row',
+ season: '2',
+ episode: '14',
+ explicit: false,
+ inFeed: true,
+ publishedAt: '2026-07-11T12:00:00.000Z',
+ durationSeconds: 3_600,
+ audioFormat: null,
+ mediaStatus: 'local',
+ localSizeBytes: 42_000_000,
+ checksum: 'a'.repeat(64),
+ positionSeconds: 600,
+ progressPercent: 16.67,
+ finished: false,
+ pinned: false,
+ queued: true,
+ lastListenedAt: '2026-07-11T12:10:00.000Z',
+ }
+}
+
+function createDetail(): PodcastEpisodeSummary {
+ return {
+ ...createEpisode(),
+ subtitle: null,
+ description: 'The complete episode description.
',
+ episodeType: null,
+ audioFormat: 'audio/mpeg',
+ chapters: [
+ { title: 'Opening', startSeconds: 0 },
+ { title: 'Main topic', startSeconds: 300 },
+ ],
+ transcripts: [{ url: 'https://cdn.example.com/transcript.vtt', type: 'text/vtt', language: 'en' }],
+ lockedFields: [],
+ pinned: false,
+ createdAt: '2026-07-11T12:00:00.000Z',
+ updatedAt: '2026-07-11T12:00:00.000Z',
+ }
+}
diff --git a/client/src/features/podcast/components/PodcastEpisodeQuickView.vue b/client/src/features/podcast/components/PodcastEpisodeQuickView.vue
new file mode 100644
index 000000000..d6c252ec5
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastEpisodeQuickView.vue
@@ -0,0 +1,224 @@
+
+
+
+
+
+
+ {{ t('podcast.actions.episodeDetails') }}
+ {{ t('podcast.fullPlayer.aboutEpisode') }}
+
+
+
+
{{ t('podcast.fullPlayer.loading') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ detail.podcastTitle }}
+
{{ detail.title }}
+
+ {{ episodeNumber }}
+ ·
+ {{ formatPodcastDate(detail.publishedAt) }}
+ ·
+ {{ formatPodcastDuration(detail.durationSeconds) }}
+ E
+
+
+
+
+
+
+
+
+
+
+ {{ isActive && isPlaying ? t('podcast.actions.pause') : t('podcast.actions.listen') }}
+
+
+ {{ t('podcast.actions.removeFromQueue') }}
+
+
+ {{ t('podcast.actions.addToQueue') }}
+
+
+ {{ t('podcast.actions.playNext') }}
+
+
+
+ {{ episode.mediaStatus === 'failed' ? t('podcast.actions.retryDownload') : t('podcast.actions.download') }}
+
+
+
+ {{ t('podcast.actions.removeDownload') }}
+
+
+ {{ t('podcast.actions.markUnplayed') }}
+
+
{{ t('podcast.actions.markPlayed') }}
+
+ {{ t('podcast.actions.editEpisodeMetadata') }}
+
+
+ {{ t('podcast.actions.openFullPlayer') }}
+
+
+
+
+ {{ t('podcast.fullPlayer.aboutEpisode') }}
+
+
+
+
+
+ {{ t('podcast.fullPlayer.chapters') }}
+ {{ formatNumber(detail.chapters.length) }}
+
+
+
+
+ {{ t('podcast.fullPlayer.transcripts') }}
+
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastEpisodeResultsPanel.spec.ts b/client/src/features/podcast/components/PodcastEpisodeResultsPanel.spec.ts
new file mode 100644
index 000000000..266e4b6dc
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastEpisodeResultsPanel.spec.ts
@@ -0,0 +1,98 @@
+import { defineComponent, h } from 'vue'
+import { mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import type { PodcastEpisodeListItem } from '@bookorbit/types'
+import PodcastEpisodeResultsPanel from './PodcastEpisodeResultsPanel.vue'
+
+vi.mock('vue-virtual-scroller', () => ({
+ DynamicScroller: defineComponent({
+ name: 'DynamicScroller',
+ props: { items: Array, pageMode: Boolean, minItemSize: Number, keyField: String },
+ setup: (props) => () => h('div', { 'data-testid': 'dynamic-scroller', 'data-count': (props.items as unknown[])?.length ?? 0 }),
+ }),
+ DynamicScrollerItem: defineComponent({
+ name: 'DynamicScrollerItem',
+ setup:
+ (_props, { slots }) =>
+ () =>
+ h('div', slots.default?.()),
+ }),
+}))
+vi.mock('vue-virtual-scroller/dist/vue-virtual-scroller.css', () => ({}))
+
+function episode(): PodcastEpisodeListItem {
+ return {
+ id: 9,
+ libraryId: 7,
+ origin: 'feed',
+ podcastId: 3,
+ podcastTitle: 'Orbit Radio',
+ podcastImageUrl: null,
+ title: 'Moonrise',
+ season: null,
+ episode: null,
+ explicit: false,
+ inFeed: true,
+ publishedAt: null,
+ durationSeconds: 600,
+ audioFormat: null,
+ mediaStatus: 'remote',
+ localSizeBytes: null,
+ checksum: null,
+ positionSeconds: 0,
+ progressPercent: 0,
+ finished: false,
+ pinned: false,
+ queued: false,
+ lastListenedAt: null,
+ }
+}
+
+function mountPanel(overrides: Partial['$props']> = {}) {
+ return mount(PodcastEpisodeResultsPanel, {
+ props: {
+ mode: 'episodes',
+ episodes: [],
+ refreshing: false,
+ canDownload: true,
+ canManageFeeds: true,
+ hasSearchQuery: false,
+ hasFiltersApplied: false,
+ currentFilter: 'latest',
+ emptyTitle: 'No episodes yet',
+ activeEpisodeId: null,
+ isPlaying: false,
+ downloadProgress: new Map(),
+ canEditMetadata: () => false,
+ ...overrides,
+ },
+ })
+}
+
+describe('PodcastEpisodeResultsPanel', () => {
+ it('keeps large episode results virtualized', () => {
+ const wrapper = mountPanel({ episodes: [episode()] })
+ const scroller = wrapper.getComponent({ name: 'DynamicScroller' })
+
+ expect(scroller.props()).toMatchObject({ pageMode: true, minItemSize: 92, keyField: 'id' })
+ expect(scroller.props('items')).toHaveLength(1)
+ })
+
+ it('uses playlist-specific recovery actions for an empty playlist', async () => {
+ const wrapper = mountPanel({ mode: 'playlists' })
+ const buttons = wrapper.findAll('button')
+
+ await buttons.find((button) => button.text().includes('Adjust rules'))!.trigger('click')
+ await buttons.find((button) => button.text().includes('Browse episodes'))!.trigger('click')
+
+ expect(wrapper.emitted('edit-playlist')).toHaveLength(1)
+ expect(wrapper.emitted('browse-episodes')).toHaveLength(1)
+ })
+
+ it('does not offer feed management when permission is absent', () => {
+ const wrapper = mountPanel({ canManageFeeds: false })
+
+ expect(wrapper.findAll('button').map((button) => button.text())).not.toContain('Add feed')
+ expect(wrapper.text()).toContain('No episodes yet')
+ })
+})
diff --git a/client/src/features/podcast/components/PodcastEpisodeResultsPanel.vue b/client/src/features/podcast/components/PodcastEpisodeResultsPanel.vue
new file mode 100644
index 000000000..1928c3d5c
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastEpisodeResultsPanel.vue
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('podcast.library.clearSearch') }}
+
+
+ {{ t('podcast.playlists.adjustRules') }}
+
+ {{ t('podcast.library.browseEpisodes') }}
+
+
+
+
+
+ {{ t('podcast.library.clearSearch') }}
+
+ {{ t('podcast.library.showAllEpisodes') }}
+
+
+ {{ t('podcast.library.browseShows') }}
+
+
+ {{ t('podcast.library.addFeed') }}
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastEpisodeRow.spec.ts b/client/src/features/podcast/components/PodcastEpisodeRow.spec.ts
new file mode 100644
index 000000000..5bf56ae73
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastEpisodeRow.spec.ts
@@ -0,0 +1,232 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import type { PodcastEpisodeListItem } from '@bookorbit/types'
+import PodcastEpisodeRow from './PodcastEpisodeRow.vue'
+import { createPodcastEpisodeActionsStub, provideEpisodeActions } from '../test/episode-action-stubs'
+
+describe('PodcastEpisodeRow', () => {
+ it('highlights and labels the actively playing episode', () => {
+ const wrapper = mountRow({ isActive: true, isPlaying: true })
+
+ expect(wrapper.get('article').attributes('aria-current')).toBe('true')
+ expect(wrapper.get('article').classes()).toContain('border-primary/50')
+ expect(wrapper.text()).toContain('Now playing')
+ })
+
+ it('keeps the active episode identifiable while playback is paused', () => {
+ const wrapper = mountRow({ isActive: true, isPlaying: false })
+
+ expect(wrapper.get('article').attributes('aria-current')).toBe('true')
+ expect(wrapper.get('article').classes()).toContain('bg-muted/30')
+ expect(wrapper.text()).toContain('Paused')
+ expect(wrapper.text()).not.toContain('Now playing')
+ })
+
+ it('does not add playback state to inactive episodes', () => {
+ const wrapper = mountRow()
+
+ expect(wrapper.get('article').attributes('aria-current')).toBeUndefined()
+ expect(wrapper.get('article').classes()).toContain('border-border/60')
+ expect(wrapper.text()).not.toContain('Now playing')
+ expect(wrapper.text()).not.toContain('Paused')
+ })
+
+ it('names every play control with the episode it starts', () => {
+ const wrapper = mountRow()
+
+ const labels = wrapper.findAll('button').map((button) => button.attributes('aria-label'))
+
+ expect(labels).toContain('Play A Better Episode Row')
+ expect(labels).toContain('Listen to A Better Episode Row')
+ expect(labels).not.toContain('Play episode')
+ })
+
+ it('falls back to the placeholder when artwork fails to load', async () => {
+ const wrapper = mountRow()
+
+ expect(wrapper.get('img').attributes('src')).toBe('/api/v1/podcasts/12/artwork')
+
+ await wrapper.get('img').trigger('error')
+
+ expect(wrapper.find('img').exists()).toBe(false)
+ expect(wrapper.find('svg').exists()).toBe(true)
+ })
+
+ it('uses semantic colors for media status', () => {
+ const downloaded = mountRow()
+ const queued = mountRow({}, { mediaStatus: 'queued' })
+ const downloading = mountRow({}, { mediaStatus: 'downloading' })
+ const failed = mountRow({}, { mediaStatus: 'failed' })
+ const unavailable = mountRow({}, { mediaStatus: 'unavailable' })
+
+ expect(findTextElement(downloaded, 'Downloaded').classes()).toContain('text-success')
+ expect(downloaded.find('[data-testid="podcast-status-icon"]').exists()).toBe(true)
+ expect(findTextElement(queued, 'Download queued').classes()).toContain('text-warning')
+ expect(queued.find('[data-testid="podcast-status-icon"]').exists()).toBe(true)
+ expect(findTextElement(downloading, 'Downloading').classes()).toContain('text-info')
+ expect(downloading.find('[data-testid="podcast-status-icon"]').exists()).toBe(true)
+ expect(findTextElement(failed, 'Download failed').classes()).toContain('text-destructive')
+ expect(failed.find('[data-testid="podcast-status-icon"]').exists()).toBe(true)
+ expect(findTextElement(unavailable, 'Unavailable').classes()).toContain('text-destructive')
+ expect(unavailable.find('[data-testid="podcast-status-icon"]').exists()).toBe(true)
+ })
+
+ it('shows episode numbering, explicit status, feed removal, and download progress', () => {
+ const wrapper = mount(PodcastEpisodeRow, {
+ props: {
+ episode: {
+ ...createEpisode(),
+ season: '2',
+ episode: '14',
+ explicit: true,
+ inFeed: false,
+ mediaStatus: 'downloading',
+ },
+ canDownload: true,
+ downloadProgress: { receivedBytes: 25, totalBytes: 100 },
+ },
+ })
+
+ expect(wrapper.text()).toContain('S2 E14')
+ expect(wrapper.get('[aria-label="Explicit"]').text()).toBe('E')
+ expect(wrapper.text()).toContain('No longer in feed')
+ expect(wrapper.get('[aria-label="Download progress"]').attributes('aria-valuenow')).toBe('25')
+ })
+
+ it('wraps status chips separately from truncated identity metadata', () => {
+ const wrapper = mountRow({}, { inFeed: false, finished: true, queued: true })
+
+ expect(wrapper.get('[data-testid="episode-status-row"]').classes()).toContain('flex-wrap')
+ expect(wrapper.get('[data-testid="episode-status-row"]').text()).toContain('Downloaded')
+ expect(wrapper.get('[data-testid="episode-status-row"]').text()).toContain('Played')
+ expect(wrapper.get('[data-testid="episode-status-row"]').text()).toContain('No longer in feed')
+ expect(wrapper.get('[data-testid="episode-status-row"]').text()).toContain('In queue')
+ })
+
+ it('shows formatted time remaining beside playback progress', () => {
+ const wrapper = mountRow({}, { durationSeconds: 4_200, positionSeconds: 600, progressPercent: 14.29 })
+
+ expect(wrapper.text()).toContain('1h 0m left')
+ expect(wrapper.get('[aria-label="Playback progress"]').attributes('aria-valuenow')).toBe('14')
+ })
+
+ it('opens episode details from the title', async () => {
+ const actions = createPodcastEpisodeActionsStub()
+ const wrapper = mountRow({}, {}, actions)
+
+ await wrapper.get('button[aria-label="Episode details for A Better Episode Row"]').trigger('click')
+
+ expect(actions.openDetails).toHaveBeenCalledWith(expect.objectContaining({ id: 665 }))
+ })
+
+ it('offers play from here in queue row actions', async () => {
+ const actions = createPodcastEpisodeActionsStub()
+ const wrapper = mountRow({ queueControls: true }, {}, actions)
+
+ await wrapper.get('button[aria-label="Episode actions"]').trigger('click')
+ await flushPromises()
+ const action = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find((item) =>
+ item.textContent?.includes('Play from here'),
+ )
+ action?.click()
+ await flushPromises()
+
+ expect(actions.play).toHaveBeenCalledWith(expect.objectContaining({ id: 665 }))
+ wrapper.unmount()
+ })
+
+ it('offers metadata editing only to a caller that may edit it', async () => {
+ const withoutPermission = mountRow()
+ await withoutPermission.get('button[aria-label="Episode actions"]').trigger('click')
+ await flushPromises()
+ expect(document.body.querySelector('[data-testid="podcast-edit-episode-metadata"]')).toBeNull()
+ withoutPermission.unmount()
+
+ const actions = createPodcastEpisodeActionsStub()
+ const wrapper = mountRow({ canEditMetadata: true }, {}, actions)
+ await wrapper.get('button[aria-label="Episode actions"]').trigger('click')
+ await flushPromises()
+ document.body.querySelector('[data-testid="podcast-edit-episode-metadata"]')?.click()
+ await flushPromises()
+
+ expect(actions.openMetadataEditor).toHaveBeenCalledWith(expect.objectContaining({ id: 665 }))
+ wrapper.unmount()
+ })
+
+ it('shows pinned state and offers pinning actions with the retention hint', async () => {
+ const unpinnedActions = createPodcastEpisodeActionsStub()
+ const unpinned = mountRow({}, {}, unpinnedActions)
+ await unpinned.get('button[aria-label="Episode actions"]').trigger('click')
+ await flushPromises()
+ const pinAction = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find((item) =>
+ item.textContent?.includes('Pin episode'),
+ )
+ expect(pinAction?.title).toBe('Pinned episodes are protected from storage retention removal.')
+ pinAction?.click()
+ await flushPromises()
+ expect(unpinnedActions.pin).toHaveBeenCalledWith(expect.objectContaining({ id: 665 }))
+ unpinned.unmount()
+
+ const pinnedActions = createPodcastEpisodeActionsStub()
+ const pinned = mountRow({}, { pinned: true }, pinnedActions)
+ expect(pinned.text()).toContain('Pinned episode')
+ await pinned.get('button[aria-label="Episode actions"]').trigger('click')
+ await flushPromises()
+ const unpinAction = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find((item) =>
+ item.textContent?.includes('Unpin episode'),
+ )
+ unpinAction?.click()
+ await flushPromises()
+ expect(pinnedActions.unpin).toHaveBeenCalledWith(expect.objectContaining({ id: 665 }))
+ pinned.unmount()
+ })
+})
+
+function mountRow(
+ playback: { isActive?: boolean; isPlaying?: boolean; queueControls?: boolean; canEditMetadata?: boolean } = {},
+ episode: Partial = {},
+ actions = createPodcastEpisodeActionsStub(),
+) {
+ return mount(PodcastEpisodeRow, {
+ props: {
+ episode: { ...createEpisode(), ...episode },
+ canDownload: true,
+ ...playback,
+ },
+ global: { provide: provideEpisodeActions(actions) },
+ })
+}
+
+function findTextElement(wrapper: ReturnType, text: string) {
+ const element = wrapper.findAll('span').find((candidate) => candidate.text() === text)
+ if (!element) throw new Error(`Missing element with text: ${text}`)
+ return element
+}
+
+function createEpisode(): PodcastEpisodeListItem {
+ return {
+ id: 665,
+ libraryId: 2,
+ origin: 'feed',
+ podcastId: 12,
+ podcastTitle: 'The Long Orbit',
+ podcastImageUrl: '/api/v1/podcasts/12/artwork',
+ title: 'A Better Episode Row',
+ season: null,
+ episode: null,
+ explicit: false,
+ inFeed: true,
+ publishedAt: '2026-07-11T12:00:00.000Z',
+ durationSeconds: 3_600,
+ audioFormat: null,
+ mediaStatus: 'local',
+ localSizeBytes: 42_000_000,
+ checksum: 'a'.repeat(64),
+ positionSeconds: 600,
+ progressPercent: 16.67,
+ finished: false,
+ pinned: false,
+ queued: true,
+ lastListenedAt: '2026-07-11T12:10:00.000Z',
+ }
+}
diff --git a/client/src/features/podcast/components/PodcastEpisodeRow.vue b/client/src/features/podcast/components/PodcastEpisodeRow.vue
new file mode 100644
index 000000000..83db18d1c
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastEpisodeRow.vue
@@ -0,0 +1,317 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ episode.podcastTitle }}
+
+
+ {{ episode.title }}
+
+
+
+
+
{{ episodeNumber }}
+
·
+
{{ formatPodcastDate(episode.publishedAt) }}
+
·
+
{{ formatPodcastDuration(episode.durationSeconds) }}
+
+ E
+
+
{{ t('podcast.labels.lastListened', { time: lastListened }) }}
+
+
+ {{ t('podcast.labels.pinnedEpisode') }}
+
+
+
+
+
+
+ {{ t('podcast.labels.timeLeft', { time: timeRemaining }) }}
+
+
+
+
+
+
+
+
+
+
+ {{ isActive && isPlaying ? t('podcast.actions.pause') : t('podcast.actions.listen') }}
+
+
+ {{ t('podcast.actions.remove') }}
+
+
+ {{ t('podcast.views.queue') }}
+
+
+
+
+
+
+
+
+
+ {{ t('podcast.actions.episodeDetails') }}
+ {{ t('podcast.actions.openFullPlayer') }}
+
+ {{ t('podcast.actions.editEpisodeMetadata') }}
+
+
+
+ {{ t('podcast.actions.playFromHere') }}
+
+
+ {{ t('podcast.actions.moveToTop') }}
+
+
+
+ {{ t('podcast.actions.removeFromQueue') }}
+
+
+ {{ t('podcast.actions.addToQueue') }}
+
+
+ {{ t('podcast.actions.playNext') }}
+
+
+
+ {{ episode.mediaStatus === 'failed' ? t('podcast.actions.retryDownload') : t('podcast.actions.download') }}
+
+
+
+ {{ t('podcast.actions.removeDownload') }}
+
+
+
+ {{ t('podcast.actions.unpinEpisode') }}
+
+
+ {{ t('podcast.actions.pinEpisode') }}
+
+
+ {{ t('podcast.actions.markUnplayed') }}
+
+ {{ t('podcast.actions.markPlayed') }}
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastEpisodeStatusChips.vue b/client/src/features/podcast/components/PodcastEpisodeStatusChips.vue
new file mode 100644
index 000000000..277ef183e
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastEpisodeStatusChips.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastFieldLabel.vue b/client/src/features/podcast/components/PodcastFieldLabel.vue
new file mode 100644
index 000000000..a0b4435be
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastFieldLabel.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+ {{ label }}
+
+
+
+
+
+
+
{{ t('podcast.edit.willLockHint') }}
+
+
diff --git a/client/src/features/podcast/components/PodcastHealthPanel.spec.ts b/client/src/features/podcast/components/PodcastHealthPanel.spec.ts
new file mode 100644
index 000000000..0ac4bdf33
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastHealthPanel.spec.ts
@@ -0,0 +1,61 @@
+import { mount, flushPromises } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastFeedHealth } from '@bookorbit/types'
+import { api } from '@/lib/api'
+import PodcastHealthPanel from './PodcastHealthPanel.vue'
+
+const toastMocks = vi.hoisted(() => ({ success: vi.fn<() => void>(), error: vi.fn<() => void>() }))
+vi.mock('vue-sonner', () => ({ toast: toastMocks }))
+vi.mock('@/lib/api', () => ({ api: vi.fn<(url: string, init?: RequestInit) => Promise>() }))
+
+const apiMock = vi.mocked(api)
+
+function item(overrides: Partial = {}): PodcastFeedHealth {
+ return {
+ podcastId: 3,
+ title: 'Orbit Radio',
+ lastRefreshAt: null,
+ lastRefreshSuccessAt: null,
+ consecutiveFailures: 1,
+ nextRefreshAt: '2026-08-01T00:00:00.000Z',
+ lastError: 'Timeout',
+ lastHttpStatus: null,
+ ...overrides,
+ }
+}
+
+describe('PodcastHealthPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ apiMock.mockResolvedValue(new Response(null, { status: 204 }))
+ })
+
+ it('renders the accessible empty state when no feeds are loaded', () => {
+ const wrapper = mount(PodcastHealthPanel, { props: { items: [], refreshing: false } })
+
+ expect(wrapper.text()).toContain('No feeds to monitor')
+ })
+
+ it('emits the selected show without owning route navigation', async () => {
+ const wrapper = mount(PodcastHealthPanel, { props: { items: [item()], refreshing: false } })
+
+ await wrapper.get('button[aria-label="Open Orbit Radio"]').trigger('click')
+
+ expect(wrapper.emitted('open-show')).toEqual([[3]])
+ })
+
+ it('queues refreshes only for unhealthy loaded feeds', async () => {
+ const wrapper = mount(PodcastHealthPanel, {
+ props: { items: [item(), item({ podcastId: 4, title: 'Healthy Feed', consecutiveFailures: 0 })], refreshing: false },
+ })
+
+ await wrapper
+ .findAll('button')
+ .find((button) => button.text() === 'Retry loaded failing feeds')!
+ .trigger('click')
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledTimes(1)
+ expect(apiMock).toHaveBeenCalledWith('/api/v1/podcasts/3/refresh', { method: 'POST' })
+ })
+})
diff --git a/client/src/features/podcast/components/PodcastHealthPanel.vue b/client/src/features/podcast/components/PodcastHealthPanel.vue
new file mode 100644
index 000000000..6ab06db27
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastHealthPanel.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
{{ t('podcast.library.manageFeeds') }}
+
+
+ {{ t('podcast.library.retryUnhealthyLoaded') }}
+
+
+
+
+
+
{{ item.title }}
+
+ {{ t('podcast.library.lastSuccess') }}:
+ {{ item.lastRefreshSuccessAt ? formatDateTime(new Date(item.lastRefreshSuccessAt)) : t('podcast.acquisition.never') }}
+
+
+
+
+
+
+
+ {{
+ item.consecutiveFailures
+ ? t('podcast.library.failureCount', {
+ count: item.consecutiveFailures,
+ error: item.lastError || t('podcast.library.unknownError'),
+ })
+ : t('podcast.library.healthy')
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastImportGroup.vue b/client/src/features/podcast/components/PodcastImportGroup.vue
new file mode 100644
index 000000000..6b3d07d6e
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastImportGroup.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+ {{ label }} ({{ formatNumber(count) }})
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastImportSheet.spec.ts b/client/src/features/podcast/components/PodcastImportSheet.spec.ts
new file mode 100644
index 000000000..1223ea8dd
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastImportSheet.spec.ts
@@ -0,0 +1,280 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { defineComponent, h } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastImportReport, PodcastImportScanStatus } from '@bookorbit/types'
+import { api } from '@/lib/api'
+import PodcastImportSheet from './PodcastImportSheet.vue'
+import { jsonResponse } from '../test/fixtures'
+
+vi.mock('@/lib/api', () => ({ api: vi.fn<(url: string, init?: RequestInit) => Promise>() }))
+vi.mock('vue-sonner', () => ({ toast: { success: vi.fn<() => void>(), error: vi.fn<() => void>() } }))
+vi.mock('../composables/usePodcastEvents', () => ({
+ usePodcastEvents: () => ({ onImportProgress: vi.fn<() => void>() }),
+}))
+
+/** The shared sheet teleports out of the wrapper, so the panel is rendered in place for these assertions. */
+vi.mock('@/components/ui/sheet', () => {
+ const passthrough = (tag: string) =>
+ defineComponent({
+ setup:
+ (_props, { slots }) =>
+ () =>
+ h(tag, slots.default?.()),
+ }) as ReturnType
+ return {
+ Sheet: passthrough('div'),
+ SheetContent: passthrough('div'),
+ SheetHeader: passthrough('div'),
+ SheetTitle: passthrough('h2'),
+ SheetDescription: passthrough('p'),
+ SheetFooter: passthrough('div'),
+ }
+})
+
+const apiMock = vi.mocked(api)
+
+function report(overrides: Partial = {}): PodcastImportReport {
+ return {
+ libraryId: 5,
+ dryRun: true,
+ startedAt: '2026-07-31T10:00:00.000Z',
+ finishedAt: '2026-07-31T10:00:04.000Z',
+ filesDiscovered: 3,
+ filesScanned: 3,
+ alreadyAdopted: 0,
+ attached: 0,
+ counts: { matched: 1, ambiguous: 1, unmatched: 1, duplicates: 0, skipped: 0, suggestedFeeds: 0, unclaimedFolders: 0 },
+ matched: [
+ {
+ path: 'Orbit Radio [7]/one.mp3',
+ fileName: 'one.mp3',
+ sizeBytes: 1024,
+ episodeId: 101,
+ podcastId: 7,
+ podcastTitle: 'Orbit Radio',
+ episodeTitle: 'Landing on the Moon',
+ tier: 'exact',
+ signals: ['episode_guid'],
+ scope: 'folder_suffix',
+ attached: false,
+ },
+ ],
+ ambiguous: [
+ {
+ path: 'Orbit Radio [7]/two.mp3',
+ fileName: 'two.mp3',
+ sizeBytes: 2048,
+ scope: 'title_similarity',
+ reason: 'single_fuzzy_signal',
+ candidates: [
+ {
+ episodeId: 202,
+ podcastId: 7,
+ podcastTitle: 'Orbit Radio',
+ episodeTitle: 'Docking Practice',
+ publishedAt: '2026-03-04T09:00:00.000Z',
+ durationSeconds: 1800,
+ signals: ['title_and_date'],
+ },
+ ],
+ },
+ ],
+ unmatched: [{ path: 'loose/three.mp3', fileName: 'three.mp3', sizeBytes: 512, reason: 'show_unresolved', podcastId: null, podcastTitle: null }],
+ duplicates: [],
+ suggestedFeeds: [],
+ unclaimedFolders: [],
+ skipped: [],
+ unreadableFolders: [],
+ quota: { adoptBytes: '1024', headroomBytes: '999999', exceeded: false },
+ blockedReason: null,
+ truncated: false,
+ ...overrides,
+ }
+}
+
+function status(overrides: Partial = {}): PodcastImportScanStatus {
+ return {
+ job: {
+ id: 9,
+ status: 'completed',
+ dryRun: true,
+ progressCurrent: 3,
+ progressTotal: 3,
+ lastError: null,
+ createdAt: '2026-07-31T10:00:00.000Z',
+ updatedAt: '2026-07-31T10:00:04.000Z',
+ },
+ report: report(),
+ ...overrides,
+ }
+}
+
+async function mountSheet(initial: PodcastImportScanStatus = status()) {
+ apiMock.mockResolvedValue(jsonResponse(initial))
+ const wrapper = mount(PodcastImportSheet, { props: { open: true, libraryId: 5 } })
+ await flushPromises()
+ return wrapper
+}
+
+describe('PodcastImportSheet', () => {
+ beforeEach(() => {
+ apiMock.mockReset()
+ })
+
+ it('loads the latest report and renders every group with its count', async () => {
+ const wrapper = await mountSheet()
+
+ expect(apiMock).toHaveBeenCalledWith('/api/v1/podcast-libraries/5/import-scan/latest', undefined)
+ expect(wrapper.text()).toContain('Landing on the Moon')
+ expect(wrapper.text()).toContain('two.mp3')
+ expect(wrapper.text()).toContain('three.mp3')
+ expect(wrapper.text()).toContain('No show could be identified for this file')
+ })
+
+ it('starts a dry run without resolutions', async () => {
+ const wrapper = await mountSheet()
+ apiMock.mockClear()
+ apiMock.mockResolvedValue(jsonResponse(status()))
+
+ await wrapper.get('[data-testid="podcast-import-scan"]').trigger('click')
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledWith(
+ '/api/v1/podcast-libraries/5/import-scan',
+ expect.objectContaining({ method: 'POST', body: JSON.stringify({ dryRun: true }) }),
+ )
+ })
+
+ it('carries the episode a user picked for an ambiguous file into the apply run', async () => {
+ const wrapper = await mountSheet()
+ apiMock.mockClear()
+ apiMock.mockResolvedValue(jsonResponse(status()))
+
+ await wrapper.get('select').setValue('202')
+ await wrapper.get('[data-testid="podcast-import-apply"]').trigger('click')
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledWith(
+ '/api/v1/podcast-libraries/5/import-scan',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ dryRun: false, resolutions: [{ path: 'Orbit Radio [7]/two.mp3', episodeId: 202 }] }),
+ }),
+ )
+ expect(wrapper.emitted('applied')).toHaveLength(1)
+ })
+
+ it('does not offer to apply a report that already wrote', async () => {
+ const wrapper = await mountSheet(status({ report: report({ dryRun: false, attached: 1 }) }))
+
+ expect(wrapper.get('[data-testid="podcast-import-apply"]').attributes('disabled')).toBeDefined()
+ expect(wrapper.text()).toContain('Adopted 1 files.')
+ })
+
+ it('shows the quota refusal instead of a success line when the run was blocked', async () => {
+ const wrapper = await mountSheet(
+ status({
+ report: report({ dryRun: false, blockedReason: 'quota_exceeded', quota: { adoptBytes: '900', headroomBytes: '10', exceeded: true } }),
+ }),
+ )
+
+ expect(wrapper.get('[role="alert"]').text()).toContain('exceed this library')
+ })
+
+ it('subscribes to a suggested feed and tells the user to scan again', async () => {
+ const wrapper = await mountSheet(
+ status({
+ report: report({
+ counts: { matched: 0, ambiguous: 0, unmatched: 0, duplicates: 0, skipped: 0, suggestedFeeds: 1, unclaimedFolders: 0 },
+ matched: [],
+ ambiguous: [],
+ unmatched: [],
+ suggestedFeeds: [{ folderPath: 'Unknown Show', feedUrl: 'https://feeds.example/unknown.xml', source: 'feed_sidecar', fileCount: 4 }],
+ }),
+ }),
+ )
+ apiMock.mockClear()
+ apiMock.mockResolvedValue(jsonResponse({ id: 11, title: 'Unknown Show' }))
+
+ const subscribe = wrapper.findAll('button').find((button) => button.text().includes('Subscribe'))
+ await subscribe!.trigger('click')
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledWith(
+ '/api/v1/podcast-libraries/5/podcasts',
+ expect.objectContaining({ method: 'POST', body: JSON.stringify({ source: 'feed', feedUrl: 'https://feeds.example/unknown.xml' }) }),
+ )
+ expect(wrapper.emitted('subscribed')).toHaveLength(1)
+ expect(wrapper.text()).toContain('Scan again')
+ })
+
+ it('previews an unclaimed folder and turns it into a local show', async () => {
+ const wrapper = await mountSheet(
+ status({
+ report: report({
+ counts: { matched: 0, ambiguous: 0, unmatched: 0, duplicates: 0, skipped: 0, suggestedFeeds: 0, unclaimedFolders: 1 },
+ matched: [],
+ ambiguous: [],
+ unmatched: [],
+ unclaimedFolders: [
+ { folderPath: 'Field Recordings', suggestedTitle: 'Dawn Chorus Diaries', suggestedAuthor: 'Ada', fileCount: 12, existingPodcastId: null },
+ ],
+ }),
+ }),
+ )
+ expect(wrapper.text()).toContain('Dawn Chorus Diaries')
+ expect(wrapper.text()).toContain('Ada')
+ apiMock.mockClear()
+ apiMock.mockResolvedValue(jsonResponse({ podcastId: 31, title: 'Dawn Chorus Diaries', created: true, jobId: 9 }))
+
+ const create = wrapper.findAll('button').find((button) => button.text().includes('Create local show'))
+ await create!.trigger('click')
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledWith(
+ '/api/v1/podcast-libraries/5/podcasts',
+ expect.objectContaining({ method: 'POST', body: JSON.stringify({ source: 'folder', folderPath: 'Field Recordings' }) }),
+ )
+ expect(wrapper.emitted('subscribed')).toHaveLength(1)
+ expect(wrapper.text()).toContain('Scan again')
+ })
+
+ it('offers to add new episodes to a folder that is already a local show', async () => {
+ const wrapper = await mountSheet(
+ status({
+ report: report({
+ counts: { matched: 0, ambiguous: 0, unmatched: 0, duplicates: 0, skipped: 0, suggestedFeeds: 0, unclaimedFolders: 1 },
+ matched: [],
+ ambiguous: [],
+ unmatched: [],
+ unclaimedFolders: [
+ { folderPath: 'Field Recordings', suggestedTitle: 'Dawn Chorus Diaries', suggestedAuthor: null, fileCount: 1, existingPodcastId: 31 },
+ ],
+ }),
+ }),
+ )
+
+ expect(wrapper.findAll('button').some((button) => button.text().includes('Add new episodes'))).toBe(true)
+ expect(wrapper.findAll('button').some((button) => button.text().includes('Create local show'))).toBe(false)
+ })
+
+ it('reports a scan that failed on the server', async () => {
+ const wrapper = await mountSheet(
+ status({
+ job: {
+ id: 9,
+ status: 'failed',
+ dryRun: false,
+ progressCurrent: 1,
+ progressTotal: 3,
+ lastError: 'Podcast library storage folder not found',
+ createdAt: '2026-07-31T10:00:00.000Z',
+ updatedAt: '2026-07-31T10:00:04.000Z',
+ },
+ report: null,
+ }),
+ )
+
+ expect(wrapper.get('[role="alert"]').text()).toBe('Podcast library storage folder not found')
+ })
+})
diff --git a/client/src/features/podcast/components/PodcastImportSheet.vue b/client/src/features/podcast/components/PodcastImportSheet.vue
new file mode 100644
index 000000000..0cc9de3cb
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastImportSheet.vue
@@ -0,0 +1,327 @@
+
+
+
+
+
+
{{ t('podcast.import.placementHint') }}
+
+
+
+ {{ t('podcast.import.scan') }}
+
+ {{ t('podcast.import.running') }}
+
+
+
+
{{ progressLabel }}
+
+
+
+
{{ importer.error.value }}
+
+ {{ importer.job.value.lastError }}
+
+
+
+
+
+
+
{{ entry.label }}
+ {{ formatNumber(entry.value) }}
+
+
+
+ {{ t('podcast.import.quota', { adopt: formatBytes(quotaAdoptBytes), headroom: formatBytes(quotaHeadroomBytes) }) }}
+
+
+ {{ t('podcast.import.truncated') }}
+
+
+
+
+ {{ t('podcast.import.reasons.quota_exceeded') }}
+
+
+ {{ t('podcast.import.applied', { count: formatNumber(report.attached) }) }}
+
+
+
+ {{ t('podcast.import.groups.suggestedFeeds') }}
+ {{ t('podcast.import.suggestedFeedsHint') }}
+
+
+
+
{{ feed.folderPath }}
+
+ {{ feed.feedUrl }} · {{ sourceLabel(feed.source) }} · {{ t('podcast.import.fileCount', { count: feed.fileCount }) }}
+
+
+
+ {{ subscribedFeedUrls.has(feed.feedUrl) ? t('podcast.import.subscribed') : t('podcast.import.subscribe') }}
+
+
+
+ {{ t('podcast.import.rescanHint') }}
+
+
+
+ {{ t('podcast.import.groups.unclaimedFolders') }}
+ {{ t('podcast.import.unclaimedFoldersHint') }}
+
+
+
+
{{ folder.suggestedTitle }}
+
+ {{ folder.folderPath }}
+ · {{ folder.suggestedAuthor }}
+ · {{ t('podcast.import.fileCount', { count: folder.fileCount }) }}
+
+
+
+ {{ localShowLabel(folder) }}
+
+
+
+ {{ t('podcast.import.localRescanHint') }}
+
+
+
+
+ {{ file.fileName }}
+ {{ file.path }} · {{ scopeLabel(file.scope) }} · {{ reasonLabel(file.reason) }}
+
+ {{ t('podcast.import.chooseEpisode') }}
+
+
+ {{ t('podcast.import.leaveUnresolved') }}
+
+ {{ candidateLabel(candidate) }}
+
+
+
+
+
+
+
+ {{ file.episodeTitle }}
+ {{ file.podcastTitle }} · {{ file.fileName }} · {{ signalsLabel(file.signals) }}
+
+
+
+
+
+ {{ file.fileName }}
+
+ {{ file.episodeTitle }} ·
+ {{ file.contentDiffers ? t('podcast.import.duplicateDiffers') : t('podcast.import.duplicateSame') }}
+
+
+
+
+
+
+ {{ file.fileName }}
+
+ {{ reasonLabel(file.reason) }} · {{ file.podcastTitle }}
+
+
+
+
+
+
+ {{ file.path }} · {{ reasonLabel(file.reason) }}
+
+
+
+
+ {{ t('podcast.import.unreadableFolders', { count: report.unreadableFolders.length }) }}
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastLiveRegion.vue b/client/src/features/podcast/components/PodcastLiveRegion.vue
new file mode 100644
index 000000000..a850db94d
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastLiveRegion.vue
@@ -0,0 +1,9 @@
+
+
+
+ {{ message }}
+
diff --git a/client/src/features/podcast/components/PodcastMiniPlayer.spec.ts b/client/src/features/podcast/components/PodcastMiniPlayer.spec.ts
new file mode 100644
index 000000000..6a28d41be
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastMiniPlayer.spec.ts
@@ -0,0 +1,268 @@
+import { computed, ref } from 'vue'
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastQueueItem } from '@bookorbit/types'
+import MediaVolumeControl from '@/components/media/MediaVolumeControl.vue'
+import { makeEpisodeSummary } from '../test/fixtures'
+import { createPlayerStub } from '../test/stubs'
+import PodcastMiniPlayer from './PodcastMiniPlayer.vue'
+
+const routerMocks = vi.hoisted(() => ({
+ push: vi.fn<(route: unknown) => void>(),
+ route: { name: 'podcast-library' },
+}))
+const toastMocks = vi.hoisted(() => ({
+ success: vi.fn<(message: string, options?: unknown) => void>(),
+ error: vi.fn<(message: string) => void>(),
+}))
+const mockUsePodcastPlayer = vi.hoisted(() => vi.fn<() => unknown>())
+
+vi.mock('vue-router', () => ({
+ useRoute: () => routerMocks.route,
+ useRouter: () => ({ push: routerMocks.push }),
+}))
+
+vi.mock('../composables/usePodcastPlayer', () => ({
+ usePodcastPlayer: mockUsePodcastPlayer,
+}))
+
+vi.mock('vue-sonner', () => ({
+ toast: toastMocks,
+}))
+
+describe('PodcastMiniPlayer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ localStorage.clear()
+ routerMocks.route.name = 'podcast-library'
+ stubViewport(false)
+ mockUsePodcastPlayer.mockReturnValue(createPlayer())
+ })
+
+ it('stays hidden without an active episode or on the full player route', () => {
+ const player = createPlayer()
+ player.episode.value = null
+ mockUsePodcastPlayer.mockReturnValue(player)
+
+ const withoutEpisode = mount(PodcastMiniPlayer)
+ expect(withoutEpisode.find('section').exists()).toBe(false)
+
+ routerMocks.route.name = 'podcast-player'
+ player.episode.value = createEpisode()
+ const onPlayerRoute = mount(PodcastMiniPlayer)
+ expect(onPlayerRoute.find('section').exists()).toBe(false)
+ })
+
+ it('provides shared playback, seeking and volume controls in expanded mode', async () => {
+ const player = createPlayer()
+ mockUsePodcastPlayer.mockReturnValue(player)
+ const wrapper = mount(PodcastMiniPlayer)
+
+ expect(wrapper.find('[data-testid="podcast-expanded-mini-player"]').exists()).toBe(true)
+ expect(wrapper.text()).toContain('The Long Orbit')
+ expect(wrapper.text()).toContain('10:00')
+ expect(wrapper.text()).toContain('-50:00')
+
+ await wrapper.get('button[aria-label="Back 15 seconds"]').trigger('click')
+ await wrapper.get('button[aria-label="Play"]').trigger('click')
+ await wrapper.get('button[aria-label="Forward 30 seconds"]').trigger('click')
+ await wrapper.get('button[aria-label="Previous episode"]').trigger('click')
+ await wrapper.get('button[aria-label="Next episode"]').trigger('click')
+ await wrapper.get('[role="slider"][aria-label="Episode position"]').trigger('keydown', { key: 'ArrowRight' })
+
+ const volumeControl = wrapper.getComponent(MediaVolumeControl)
+ volumeControl.vm.$emit('update:volume', 0.4)
+ volumeControl.vm.$emit('toggleMute')
+
+ expect(player.skipBackward).toHaveBeenCalledOnce()
+ expect(player.togglePlayback).toHaveBeenCalledOnce()
+ expect(player.skipForward).toHaveBeenCalledOnce()
+ expect(player.playPrevious).toHaveBeenCalledOnce()
+ expect(player.playNext).toHaveBeenCalledOnce()
+ expect(player.seekTo).toHaveBeenCalledExactlyOnceWith(605)
+ expect(player.setVolume).toHaveBeenCalledWith(0.4)
+ expect(player.toggleMute).toHaveBeenCalledOnce()
+ })
+
+ it('disables unavailable episode navigation in expanded mode', () => {
+ const player = createPlayer()
+ player.previousQueueItem.value = null
+ player.nextQueueItem.value = null
+ mockUsePodcastPlayer.mockReturnValue(player)
+
+ const wrapper = mount(PodcastMiniPlayer)
+
+ expect(wrapper.get('button[aria-label="Previous episode"]').attributes('disabled')).toBeDefined()
+ expect(wrapper.get('button[aria-label="Next episode"]').attributes('disabled')).toBeDefined()
+ })
+
+ it('collapses without stopping playback and persists the selected mode', async () => {
+ const player = createPlayer()
+ mockUsePodcastPlayer.mockReturnValue(player)
+ const wrapper = mount(PodcastMiniPlayer)
+
+ await wrapper.get('button[aria-label="Collapse to compact player"]').trigger('click')
+
+ expect(wrapper.find('[data-testid="podcast-super-mini-player"]').exists()).toBe(true)
+ expect(localStorage.getItem('bookorbit:podcast-mini-player-mode')).toBe('compact')
+ expect(player.clearPlayer).not.toHaveBeenCalled()
+
+ await wrapper.get('button[aria-label="Expand mini player"]').trigger('click')
+
+ expect(wrapper.find('[data-testid="podcast-expanded-mini-player"]').exists()).toBe(true)
+ expect(localStorage.getItem('bookorbit:podcast-mini-player-mode')).toBe('expanded')
+ })
+
+ it('restores a persisted super-mini preference', () => {
+ localStorage.setItem('bookorbit:podcast-mini-player-mode', 'compact')
+
+ const wrapper = mount(PodcastMiniPlayer)
+
+ expect(wrapper.find('[data-testid="podcast-super-mini-player"]').exists()).toBe(true)
+ })
+
+ it('defaults to super-mini mode on narrow screens', () => {
+ stubViewport(true)
+
+ const wrapper = mount(PodcastMiniPlayer)
+
+ expect(wrapper.find('[data-testid="podcast-super-mini-player"]').exists()).toBe(true)
+ })
+
+ it('opens the full player from the super-mini episode button', async () => {
+ localStorage.setItem('bookorbit:podcast-mini-player-mode', 'compact')
+ const wrapper = mount(PodcastMiniPlayer)
+
+ await wrapper.findAll('button[aria-label="Open full player"]')[1]!.trigger('click')
+
+ expect(routerMocks.push).toHaveBeenCalledWith({ name: 'podcast-player', params: { episodeId: 665 } })
+ })
+
+ it('opens the full player from the explicit expanded control', async () => {
+ const wrapper = mount(PodcastMiniPlayer)
+
+ await wrapper.findAll('button[aria-label="Open full player"]').at(-1)!.trigger('click')
+
+ expect(routerMocks.push).toHaveBeenCalledWith({ name: 'podcast-player', params: { episodeId: 665 } })
+ })
+
+ it('cycles playback rate from the expanded control', async () => {
+ const player = createPlayer()
+ player.playbackRate.value = 1.25
+ mockUsePodcastPlayer.mockReturnValue(player)
+ const wrapper = mount(PodcastMiniPlayer)
+
+ expect(wrapper.get('button[aria-label="Cycle playback speed"]').text()).toBe('1.25x')
+ await wrapper.get('button[aria-label="Cycle playback speed"]').trigger('click')
+
+ expect(player.cyclePlaybackRate).toHaveBeenCalledOnce()
+ })
+
+ it('shows an active sleep timer with extend and turn-off controls', async () => {
+ const player = createPlayer()
+ player.sleepTimerMinutes.value = 15
+ player.sleepRemainingSeconds.value = 720
+ mockUsePodcastPlayer.mockReturnValue(player)
+ const popoverStub = { template: '
' }
+ const wrapper = mount(PodcastMiniPlayer, {
+ global: {
+ stubs: {
+ Popover: popoverStub,
+ PopoverTrigger: popoverStub,
+ PopoverContent: popoverStub,
+ },
+ },
+ })
+
+ expect(wrapper.get('button[aria-label="Sleep timer active: 12 min"]').text()).toContain('12 min')
+ await wrapper.get('button[aria-label="Add 5 more minutes to the sleep timer"]').trigger('click')
+ await wrapper
+ .findAll('button')
+ .find((button) => button.text() === 'Off')!
+ .trigger('click')
+
+ expect(player.extendSleepTimer).toHaveBeenCalledWith(5)
+ expect(player.clearSleepTimer).toHaveBeenCalledOnce()
+ })
+
+ it('offers an undo action after stopping and closing the player', async () => {
+ const player = createPlayer()
+ mockUsePodcastPlayer.mockReturnValue(player)
+ const wrapper = mount(PodcastMiniPlayer)
+
+ await wrapper.get('button[aria-label="Stop and close player"]').trigger('click')
+
+ expect(player.clearPlayer).toHaveBeenCalledOnce()
+ expect(localStorage.getItem('bookorbit:podcast-mini-player-mode')).toBeNull()
+ expect(toastMocks.success).toHaveBeenCalledWith('Player closed', expect.any(Object))
+
+ const options = toastMocks.success.mock.calls[0]![1] as { action: { label: string; onClick: () => void } }
+ expect(options.action.label).toBe('Undo')
+ options.action.onClick()
+ expect(player.loadEpisode).toHaveBeenCalledWith(665, false)
+ })
+
+ it('shows pending playback and buffered progress in compact mode', () => {
+ localStorage.setItem('bookorbit:podcast-mini-player-mode', 'compact')
+ const player = createPlayer()
+ player.isBuffering.value = true
+ player.bufferedRanges.value = [{ start: 0, end: 1_200 }]
+ mockUsePodcastPlayer.mockReturnValue(player)
+ const wrapper = mount(PodcastMiniPlayer)
+
+ expect(wrapper.get('button[aria-label="Play"] svg').classes()).toContain('animate-spin')
+ expect(wrapper.get('[data-testid="podcast-mini-player-buffered"]').attributes('style')).toContain('width: 33.3333')
+ })
+
+ it('shows playback errors with retry in compact mode', async () => {
+ localStorage.setItem('bookorbit:podcast-mini-player-mode', 'compact')
+ const player = createPlayer()
+ player.error.value = 'The episode could not be loaded.'
+ mockUsePodcastPlayer.mockReturnValue(player)
+ const wrapper = mount(PodcastMiniPlayer)
+
+ expect(wrapper.get('[role="alert"]').text()).toContain('The episode could not be loaded.')
+ await wrapper
+ .findAll('button')
+ .find((button) => button.text() === 'Retry')!
+ .trigger('click')
+
+ expect(player.playInline).toHaveBeenCalledWith(665)
+ })
+})
+
+/** jsdom has no matchMedia, and useMediaQuery subscribes to the returned list, so the stub needs listeners. */
+function stubViewport(matchesCompact: boolean) {
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn<(query: string) => Partial>().mockReturnValue({
+ matches: matchesCompact,
+ addEventListener: vi.fn<() => void>(),
+ removeEventListener: vi.fn<() => void>(),
+ }),
+ )
+}
+
+function createEpisode() {
+ return makeEpisodeSummary({
+ id: 665,
+ podcastId: 12,
+ podcastTitle: 'The Long Orbit',
+ title: 'A Better Mini Player',
+ podcastImageUrl: 'https://feed.example/cover.jpg',
+ durationSeconds: 3_600,
+ })
+}
+
+function createPlayer() {
+ const duration = ref(3_600)
+ const currentTime = ref(600)
+ return createPlayerStub({
+ episode: ref(createEpisode()),
+ duration,
+ currentTime,
+ progressPercent: computed(() => (currentTime.value / duration.value) * 100),
+ previousQueueItem: ref({ id: 664 } as PodcastQueueItem),
+ nextQueueItem: ref({ id: 666 } as PodcastQueueItem),
+ })
+}
diff --git a/client/src/features/podcast/components/PodcastMiniPlayer.vue b/client/src/features/podcast/components/PodcastMiniPlayer.vue
new file mode 100644
index 000000000..0efb57fd8
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastMiniPlayer.vue
@@ -0,0 +1,436 @@
+
+
+
+
+
+
+ {{ playbackAnnouncement }}
+
+
+
+
+
+
+
+ {{ activeEpisode.podcastTitle }}
+ {{ activeEpisode.title }}
+
+
{{ formatPlaybackClock(player.currentTime.value) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ activeEpisode.podcastTitle }}
+ {{ activeEpisode.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatPlaybackClock(player.currentTime.value) }}
+
+ {{
+ t('podcast.labels.remainingClock', { time: formatPlaybackClock(remainingDuration) })
+ }}
+
+
+
+
+
+
+
+
+ {{ playbackRateLabel }}
+
+
+
+
+
+ {{ sleepStatusLabel }}
+
+
+
+
+ +
+ {{ t('podcast.fullPlayer.sleepMinutes', { count: SLEEP_EXTEND_MINUTES }) }}
+
+
+ {{ t('podcast.fullPlayer.off') }}
+
+
+
+
+
+
+
+
+ {{ t('podcast.library.upNext') }}
+ {{ player.upcomingQueueItems.value.length }}
+
+
+
+
+
+
+
+
{{ player.error.value }}
+
+ {{ t('common.retry') }}
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastOpmlImportSheet.spec.ts b/client/src/features/podcast/components/PodcastOpmlImportSheet.spec.ts
new file mode 100644
index 000000000..21e4045a5
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastOpmlImportSheet.spec.ts
@@ -0,0 +1,60 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { api } from '@/lib/api'
+import { jsonResponse } from '../test/fixtures'
+import { sheetStubs } from '../test/stubs'
+import PodcastOpmlImportSheet from './PodcastOpmlImportSheet.vue'
+
+vi.mock('@/lib/api', () => ({
+ api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(async () => new Response(null, { status: 204 })),
+}))
+const toastMocks = vi.hoisted(() => ({ success: vi.fn<() => void>(), error: vi.fn<() => void>(), info: vi.fn<() => void>() }))
+vi.mock('vue-sonner', () => ({ toast: toastMocks }))
+
+const apiMock = vi.mocked(api)
+
+function mountSheet() {
+ return mount(PodcastOpmlImportSheet, {
+ props: { open: true, libraryId: 7 },
+ global: { stubs: sheetStubs() },
+ })
+}
+
+describe('PodcastOpmlImportSheet', () => {
+ beforeEach(() => {
+ apiMock.mockReset()
+ apiMock.mockResolvedValue(jsonResponse({ total: 12, queued: 12 }))
+ toastMocks.success.mockClear()
+ })
+
+ it('applies the chosen acquisition policy to every feed in the import', async () => {
+ const wrapper = mountSheet()
+ expect(wrapper.text()).toContain('No file chosen')
+
+ const fileInput = wrapper.get('input[type="file"]')
+ Object.defineProperty(fileInput.element, 'files', { configurable: true, value: [new File([' '], 'shows.opml')] })
+ await fileInput.trigger('change')
+ expect(wrapper.get('[data-testid="podcast-import-filename"]').text()).toBe('shows.opml')
+
+ await wrapper.get('select[aria-label="Automatic downloads"]').setValue('newest')
+ await wrapper
+ .findAll('button')
+ .find((button) => button.text() === 'Import')!
+ .trigger('click')
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledWith(
+ '/api/v1/podcast-libraries/7/opml/import',
+ expect.objectContaining({ body: JSON.stringify({ opml: ' ', acquisitionPolicy: 'newest', autoDownloadLimit: 3 }) }),
+ )
+ expect(toastMocks.success).toHaveBeenCalledWith('Queued 12 podcast feeds')
+ expect(wrapper.emitted('imported')).toEqual([[{ total: 12, queued: 12 }]])
+ })
+
+ it('keeps the import disabled until a file is chosen', () => {
+ const wrapper = mountSheet()
+
+ const importButton = wrapper.findAll('button').find((button) => button.text() === 'Import')!
+ expect(importButton.attributes('disabled')).toBeDefined()
+ })
+})
diff --git a/client/src/features/podcast/components/PodcastOpmlImportSheet.vue b/client/src/features/podcast/components/PodcastOpmlImportSheet.vue
new file mode 100644
index 000000000..90fd536f4
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastOpmlImportSheet.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
{{ t('podcast.library.opmlFile') }}
+
+
+ {{ t('podcast.library.chooseOpmlFile') }}
+
+
+ {{ importFile ? importFile.name : t('podcast.library.noOpmlFileChosen') }}
+
+
+
+
+
{{ t('podcast.library.importPolicyHint') }}
+
+
+
diff --git a/client/src/features/podcast/components/PodcastPlaylistEditor.spec.ts b/client/src/features/podcast/components/PodcastPlaylistEditor.spec.ts
new file mode 100644
index 000000000..4c5ace58b
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastPlaylistEditor.spec.ts
@@ -0,0 +1,198 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { defineComponent, h } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastListItem } from '@bookorbit/types'
+import { api } from '@/lib/api'
+import PodcastPlaylistEditor from './PodcastPlaylistEditor.vue'
+import { normalizePlaylistRules } from '../lib/podcast-playlist-rules'
+import type { PodcastPlaylistOption } from '../composables/usePodcastPlaylists'
+
+vi.mock('@/lib/api', () => ({ api: vi.fn<(url: string, init?: RequestInit) => Promise>() }))
+
+/** The shared sheet teleports out of the wrapper, so the panel is rendered in place for these assertions. */
+vi.mock('@/components/ui/sheet', () => {
+ const passthrough = (tag: string) =>
+ defineComponent({
+ setup:
+ (_props, { slots }) =>
+ () =>
+ h(tag, slots.default?.()),
+ }) as ReturnType
+ return {
+ Sheet: passthrough('div'),
+ SheetContent: passthrough('div'),
+ SheetHeader: passthrough('div'),
+ SheetTitle: passthrough('h2'),
+ SheetDescription: passthrough('p'),
+ SheetFooter: passthrough('div'),
+ }
+})
+
+const apiMock = vi.mocked(api)
+
+describe('PodcastPlaylistEditor', () => {
+ beforeEach(() => {
+ apiMock.mockReset()
+ apiMock.mockResolvedValue(new Response(JSON.stringify({ items: [], total: 0, page: 1, size: 8 }), { status: 200 }))
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ })
+
+ it('seeds a new playlist from the built-in it was opened with', async () => {
+ const wrapper = await mountEditor(builtIn())
+
+ expect((wrapper.get('#podcast-playlist-name').element as HTMLInputElement).value).toBe('Quick listens copy')
+ expect((wrapper.get('#podcast-playlist-filter').element as HTMLSelectElement).value).toBe('unplayed')
+ expect((wrapper.get('#podcast-playlist-max').element as HTMLInputElement).value).toBe('20')
+ expect(wrapper.findAll('button').map((button) => button.text())).not.toContain('Delete playlist')
+ })
+
+ it('refuses to save an unnamed playlist or an impossible duration range', async () => {
+ const wrapper = await mountEditor(builtIn())
+
+ await wrapper.get('#podcast-playlist-name').setValue(' ')
+ await saveEditor(wrapper)
+ expect(wrapper.emitted('save')).toBeUndefined()
+ expect(wrapper.get('[role="alert"]').text()).toBe('Name the playlist before saving it')
+
+ await wrapper.get('#podcast-playlist-name').setValue('Long walk')
+ await wrapper.get('#podcast-playlist-min').setValue('90')
+ await wrapper.get('#podcast-playlist-max').setValue('20')
+ await saveEditor(wrapper)
+
+ expect(wrapper.emitted('save')).toBeUndefined()
+ expect(wrapper.get('[role="alert"]').text()).toBe('The shortest duration must be below the longest')
+ })
+
+ it('emits the trimmed name and the edited rules', async () => {
+ const wrapper = await mountEditor(builtIn())
+
+ await wrapper.get('#podcast-playlist-name').setValue(' Short hops ')
+ await wrapper.get('#podcast-playlist-sort').setValue('longest')
+ await wrapper.get('#podcast-playlist-published').setValue('14')
+ await saveEditor(wrapper)
+
+ expect(wrapper.emitted('save')?.[0]?.[0]).toMatchObject({
+ name: 'Short hops',
+ rules: { filter: 'unplayed', sort: 'longest', maxDurationMinutes: 20, publishedWithinDays: 14 },
+ })
+ })
+
+ it('offers pinned episodes as a shared playlist filter', async () => {
+ const wrapper = await mountEditor(builtIn())
+ const options = wrapper.findAll('#podcast-playlist-filter option')
+
+ expect(options.map((option) => option.attributes('value'))).toContain('pinned')
+ expect(options.find((option) => option.attributes('value') === 'pinned')?.text()).toBe('Pinned')
+ })
+
+ it('adds a show from a debounced search and keeps it out of the query until then', async () => {
+ const wrapper = await mountEditor(builtIn())
+ apiMock.mockClear()
+ apiMock.mockResolvedValue(new Response(JSON.stringify({ items: [show()], total: 1, page: 1, size: 8 }), { status: 200 }))
+
+ await wrapper.get('input[type="search"]').setValue('orbit')
+ expect(apiMock).not.toHaveBeenCalled()
+
+ await vi.advanceTimersByTimeAsync(300)
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledWith(expect.stringContaining('q=orbit'))
+ await wrapper
+ .findAll('button')
+ .find((button) => button.text() === 'Orbit Radio')!
+ .trigger('click')
+ await saveEditor(wrapper)
+
+ expect(wrapper.emitted('save')?.[0]?.[0]).toMatchObject({ rules: { podcastIds: [3] } })
+ })
+
+ it('previews the match once the edits settle, rather than counting on every keystroke', async () => {
+ const wrapper = await mountEditor(builtIn())
+ apiMock.mockClear()
+ apiMock.mockResolvedValue(
+ new Response(JSON.stringify({ items: [], total: 142, totalDurationSeconds: 220_800, page: 0, size: 1 }), { status: 200 }),
+ )
+
+ await wrapper.get('#podcast-playlist-published').setValue('14')
+ expect(apiMock).not.toHaveBeenCalled()
+
+ await vi.advanceTimersByTimeAsync(400)
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledWith(expect.stringContaining('/api/v1/podcast-libraries/7/episodes?page=0&size=1'), undefined)
+ expect(wrapper.get('[data-testid="podcast-playlist-preview"]').text()).toBe('142 episodes, 61h 20m')
+ })
+
+ it('names the empty match instead of previewing zero episodes of nothing', async () => {
+ const wrapper = await mountEditor(builtIn())
+
+ await vi.advanceTimersByTimeAsync(400)
+ await flushPromises()
+
+ expect(wrapper.get('[data-testid="podcast-playlist-preview"]').text()).toBe('Nothing matches this playlist yet')
+ })
+
+ it('resolves the saved show ids of an existing playlist in one request', async () => {
+ apiMock.mockResolvedValue(new Response(JSON.stringify({ items: [show()], total: 1, page: 1, size: 50 }), { status: 200 }))
+ const wrapper = await mountEditor(saved())
+
+ expect(apiMock).toHaveBeenCalledTimes(1)
+ expect(apiMock).toHaveBeenCalledWith(expect.stringContaining('podcastIds=3'))
+ expect(wrapper.text()).toContain('Orbit Radio')
+ expect(wrapper.findAll('button').map((button) => button.text())).toContain('Delete playlist')
+ })
+})
+
+async function mountEditor(playlist: PodcastPlaylistOption) {
+ const wrapper = mount(PodcastPlaylistEditor, {
+ props: { open: true, libraryId: 7, playlist, saving: false },
+ })
+ await flushPromises()
+ return wrapper
+}
+
+async function saveEditor(wrapper: Awaited>) {
+ await wrapper
+ .findAll('button')
+ .find((button) => button.text() === 'Save playlist')!
+ .trigger('click')
+}
+
+function builtIn(): PodcastPlaylistOption {
+ return {
+ id: 'quick',
+ name: 'Quick listens',
+ builtIn: true,
+ rules: normalizePlaylistRules({ filter: 'unplayed', sort: 'shortest', maxDurationMinutes: 20 }),
+ }
+}
+
+function saved(): PodcastPlaylistOption {
+ return {
+ id: 'playlist-1',
+ name: 'Morning commute',
+ builtIn: false,
+ rules: normalizePlaylistRules({ filter: 'unplayed', podcastIds: [3] }),
+ }
+}
+
+function show(): PodcastListItem {
+ return {
+ id: 3,
+ libraryId: 7,
+ origin: 'feed',
+ title: 'Orbit Radio',
+ author: 'Orbit',
+ imageUrl: null,
+ episodeCount: 12,
+ unplayedCount: 3,
+ downloadedCount: 1,
+ latestPublishedAt: null,
+ consecutiveFailures: 0,
+ playbackRecommendation: null,
+ followed: false,
+ notificationMode: 'off',
+ archivedAt: null,
+ missingAt: null,
+ }
+}
diff --git a/client/src/features/podcast/components/PodcastPlaylistEditor.vue b/client/src/features/podcast/components/PodcastPlaylistEditor.vue
new file mode 100644
index 000000000..76128cfbf
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastPlaylistEditor.vue
@@ -0,0 +1,398 @@
+
+
+
+
+
+
+
+
+ {{ t('podcast.playlists.delete') }}
+
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastPlaylistRuleSummary.vue b/client/src/features/podcast/components/PodcastPlaylistRuleSummary.vue
new file mode 100644
index 000000000..8de28346c
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastPlaylistRuleSummary.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+ {{ chip.label }}
+ {{ chip.value }}
+
+
+
+ {{ t('podcast.library.followedOnly') }}
+
+
+
+
+
+ {{ t('views.bookView.sort') }}
+
+ {{ sortLabel }}
+
+
+
diff --git a/client/src/features/podcast/components/PodcastQueueList.spec.ts b/client/src/features/podcast/components/PodcastQueueList.spec.ts
new file mode 100644
index 000000000..de4e71c8d
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastQueueList.spec.ts
@@ -0,0 +1,132 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastQueueItem } from '@bookorbit/types'
+import PodcastEpisodeRow from './PodcastEpisodeRow.vue'
+import PodcastQueueList from './PodcastQueueList.vue'
+import { createPodcastEpisodeActionsStub, provideEpisodeActions } from '../test/episode-action-stubs'
+
+const queueMock = vi.hoisted(() => ({ reorder: vi.fn<(episodeIds: number[]) => Promise>(async () => undefined) }))
+const toastMocks = vi.hoisted(() => ({ success: vi.fn<() => void>(), error: vi.fn<() => void>() }))
+
+vi.mock('vue-sonner', () => ({ toast: toastMocks }))
+vi.mock('../composables/usePodcastQueue', () => ({ usePodcastQueue: () => queueMock, notifyEpisodesDequeued: vi.fn<() => void>() }))
+
+describe('PodcastQueueList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('renders full episode rows with reordering and selection for the library tab', () => {
+ const wrapper = mountList({ reorderable: true, selectable: true, selectedIds: new Set([2]) })
+
+ expect(wrapper.findAllComponents(PodcastEpisodeRow)).toHaveLength(2)
+ expect(wrapper.findAll('button[aria-label^="Reorder"]')).toHaveLength(2)
+ const checkboxes = wrapper.findAll('input[type="checkbox"]')
+ expect(checkboxes).toHaveLength(2)
+ expect((checkboxes[1]!.element as HTMLInputElement).checked).toBe(true)
+ expect(wrapper.findAll('[data-testid="podcast-queue-position"]').map((position) => position.text())).toEqual(['1', '2'])
+ })
+
+ it('reports selection changes without owning the selected set', async () => {
+ const wrapper = mountList({ reorderable: true, selectable: true, selectedIds: new Set() })
+
+ await wrapper.findAll('input[type="checkbox"]')[0]!.setValue(true)
+
+ expect(wrapper.emitted('toggleSelect')).toEqual([[1, true]])
+ })
+
+ it('updates position numbers immediately during keyboard reordering', async () => {
+ const wrapper = mountList({ reorderable: true })
+ const firstGrip = wrapper.findAll('button[aria-label^="Reorder"]')[0]!
+
+ await firstGrip.trigger('keydown', { key: ' ' })
+ await firstGrip.trigger('keydown', { key: 'ArrowDown' })
+
+ expect(wrapper.findAllComponents(PodcastEpisodeRow).map((row) => row.props('episode').id)).toEqual([2, 1])
+ expect(wrapper.findAll('[data-testid="podcast-queue-position"]').map((position) => position.text())).toEqual(['1', '2'])
+ })
+
+ it('persists a move to the top as one bulk reorder request', async () => {
+ const wrapper = mountList({ reorderable: true })
+
+ wrapper.findAllComponents(PodcastEpisodeRow)[1]!.vm.$emit('moveTop', queueItem(2))
+ await flushPromises()
+
+ expect(queueMock.reorder).toHaveBeenCalledTimes(1)
+ expect(queueMock.reorder).toHaveBeenCalledWith([2, 1])
+ })
+
+ it('restores the previous order and offers a retry when persistence fails', async () => {
+ queueMock.reorder.mockRejectedValueOnce(new Error('nope'))
+ const wrapper = mountList({ reorderable: true })
+
+ wrapper.findAllComponents(PodcastEpisodeRow)[1]!.vm.$emit('moveTop', queueItem(2))
+ await flushPromises()
+
+ expect(toastMocks.error).toHaveBeenCalledWith('nope')
+ expect(wrapper.findAllComponents(PodcastEpisodeRow).map((row) => row.props('episode').id)).toEqual([1, 2])
+ })
+
+ it('renders the dense up-next list the players share, with positions and the next marker', () => {
+ const wrapper = mountList({ density: 'compact', showPosition: true, nextPosition: 1 })
+
+ expect(wrapper.findAllComponents(PodcastEpisodeRow)).toHaveLength(0)
+ expect(wrapper.findAll('button[aria-label^="Reorder"]')).toHaveLength(0)
+ expect(wrapper.text()).toContain('Next')
+ expect(wrapper.text()).toContain('Episode 1')
+ expect(wrapper.text()).toContain('Episode 2')
+ })
+
+ it('drives play and unqueue from the dense list', async () => {
+ const actions = createPodcastEpisodeActionsStub()
+ const wrapper = mountList({ density: 'compact' }, actions)
+
+ await wrapper.findAll('button')[0]!.trigger('click')
+ await wrapper.get('button[aria-label="Remove Episode 1 from queue"]').trigger('click')
+
+ expect(actions.play).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }))
+ expect(actions.unqueue).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }))
+ })
+
+ it('renders nothing when the queue is empty', () => {
+ const wrapper = mount(PodcastQueueList, { props: { items: [], canDownload: true } })
+
+ expect(wrapper.find('div').exists()).toBe(false)
+ })
+})
+
+function mountList(props: Record = {}, actions = createPodcastEpisodeActionsStub()) {
+ return mount(PodcastQueueList, {
+ props: { items: [queueItem(1), queueItem(2)], canDownload: true, ...props },
+ global: { provide: provideEpisodeActions(actions) },
+ })
+}
+
+function queueItem(id: number): PodcastQueueItem {
+ return {
+ id,
+ libraryId: 7,
+ origin: 'feed',
+ podcastId: 3,
+ podcastTitle: 'Orbit Radio',
+ podcastImageUrl: null,
+ title: `Episode ${id}`,
+ season: null,
+ episode: null,
+ explicit: false,
+ inFeed: true,
+ publishedAt: '2026-07-20T00:00:00.000Z',
+ durationSeconds: 1_200,
+ audioFormat: null,
+ mediaStatus: 'remote',
+ localSizeBytes: null,
+ checksum: null,
+ positionSeconds: 0,
+ progressPercent: 0,
+ finished: false,
+ pinned: false,
+ queued: true,
+ lastListenedAt: null,
+ queuePosition: id - 1,
+ }
+}
diff --git a/client/src/features/podcast/components/PodcastQueueList.vue b/client/src/features/podcast/components/PodcastQueueList.vue
new file mode 100644
index 000000000..1424bd4a8
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastQueueList.vue
@@ -0,0 +1,222 @@
+
+
+
+
+
+
+
+
+ {{ index + 1 }}
+
+
+
+
+
+
+ {{ t('podcast.actions.selectEpisode', { title: episode.title }) }}
+
+
+
+
+
+
+
+
+
+
+ {{ episode.queuePosition + 1 }}
+
+
+
+ {{ t('common.next') }}
+
+ {{ episode.title }}
+
+ {{ episode.podcastTitle }}
+
+ {{ formatPodcastDuration(episode.durationSeconds) }}
+
+
+
+
+
+
+
+
+
+
{{ reorderAnnouncement }}
+
+
diff --git a/client/src/features/podcast/components/PodcastQueuePanel.spec.ts b/client/src/features/podcast/components/PodcastQueuePanel.spec.ts
new file mode 100644
index 000000000..4337a0c93
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastQueuePanel.spec.ts
@@ -0,0 +1,158 @@
+import { defineComponent, h } from 'vue'
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastEpisodeListItem, PodcastQueueItem } from '@bookorbit/types'
+import PodcastQueueList from './PodcastQueueList.vue'
+import PodcastQueuePanel from './PodcastQueuePanel.vue'
+
+const toastMocks = vi.hoisted(() => ({ success: vi.fn<() => void>(), error: vi.fn<() => void>() }))
+const apiMock = vi.hoisted(() => vi.fn<(url: string, init?: RequestInit) => Promise>(async () => new Response(null, { status: 204 })))
+vi.mock('vue-sonner', () => ({ toast: toastMocks }))
+vi.mock('@/lib/api', async (importOriginal) => ({ ...(await importOriginal()), api: apiMock }))
+
+function item(id: number, overrides: Partial = {}): PodcastQueueItem {
+ return {
+ id,
+ libraryId: 7,
+ origin: 'feed',
+ podcastId: 3,
+ podcastTitle: 'Orbit Radio',
+ podcastImageUrl: null,
+ title: `Episode ${id}`,
+ season: null,
+ episode: null,
+ explicit: false,
+ inFeed: true,
+ publishedAt: null,
+ durationSeconds: 600,
+ audioFormat: null,
+ mediaStatus: 'remote',
+ localSizeBytes: null,
+ checksum: null,
+ positionSeconds: 0,
+ progressPercent: 0,
+ finished: false,
+ pinned: false,
+ queued: true,
+ lastListenedAt: null,
+ queuePosition: id - 1,
+ ...overrides,
+ }
+}
+
+const passthrough = defineComponent({
+ setup:
+ (_props, { slots }) =>
+ () =>
+ h('div', [slots.default?.(), slots.content?.()]),
+})
+
+function mountPanel(overrides: Partial['$props']> = {}) {
+ const removeFromQueue = vi.fn<(episode: PodcastEpisodeListItem) => Promise>(async () => undefined)
+ const reload = vi.fn<() => Promise>(async () => undefined)
+ const wrapper = mount(PodcastQueuePanel, {
+ props: {
+ items: [item(1), item(2)],
+ total: 2,
+ durationSeconds: 1200,
+ refreshing: false,
+ hasSearchQuery: false,
+ canDownload: true,
+ selectionMode: true,
+ activeEpisodeId: null,
+ isPlaying: false,
+ downloadProgress: new Map(),
+ removeFromQueue,
+ reload,
+ ...overrides,
+ },
+ global: {
+ stubs: {
+ PodcastQueueList: true,
+ SelectionActionBar: passthrough,
+ Tooltip: passthrough,
+ TooltipTrigger: passthrough,
+ TooltipContent: passthrough,
+ },
+ },
+ })
+ return { wrapper, removeFromQueue, reload }
+}
+
+function stateWrites(): Array<[string, unknown]> {
+ return apiMock.mock.calls
+ .filter(([url, init]) => url.endsWith('/state') && init?.method === 'PATCH')
+ .map(([url, init]) => [url, JSON.parse(String(init?.body)) as unknown])
+}
+
+describe('PodcastQueuePanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ apiMock.mockResolvedValue(new Response(null, { status: 204 }))
+ })
+
+ it('marks only the selected unfinished episodes as played', async () => {
+ const { wrapper } = mountPanel({ items: [item(1, { finished: true }), item(2)] })
+ wrapper.findComponent(PodcastQueueList).vm.$emit('toggleSelect', 1, true)
+ wrapper.findComponent(PodcastQueueList).vm.$emit('toggleSelect', 2, true)
+ await wrapper.vm.$nextTick()
+ await wrapper.get('[data-testid="podcast-bulk-mark-played"]').trigger('click')
+ await flushPromises()
+
+ expect(stateWrites()).toEqual([['/api/v1/podcast-episodes/2/state', { finished: true }]])
+ expect(wrapper.get('[data-testid="podcast-bulk-outcome"]').text()).toContain('1')
+ })
+
+ it('reports the failures in a bulk update rather than claiming success', async () => {
+ apiMock.mockResolvedValueOnce(new Response(null, { status: 500 }))
+ const { wrapper } = mountPanel({ items: [item(1)] })
+ wrapper.findComponent(PodcastQueueList).vm.$emit('toggleSelect', 1, true)
+ await wrapper.vm.$nextTick()
+ await wrapper.get('[data-testid="podcast-bulk-mark-played"]').trigger('click')
+ await flushPromises()
+
+ expect(toastMocks.error).toHaveBeenCalledOnce()
+ expect(wrapper.get('[data-testid="podcast-bulk-outcome"]').text()).toContain('1')
+ })
+
+ it('downloads only the selected episodes that have no local file yet', async () => {
+ const { wrapper } = mountPanel({ items: [item(1, { mediaStatus: 'local' }), item(2)] })
+ wrapper.findComponent(PodcastQueueList).vm.$emit('toggleSelect', 1, true)
+ wrapper.findComponent(PodcastQueueList).vm.$emit('toggleSelect', 2, true)
+ await wrapper.vm.$nextTick()
+ await wrapper.get('[data-testid="podcast-bulk-download"]').trigger('click')
+ await flushPromises()
+
+ expect(apiMock.mock.calls.filter(([url]) => url.endsWith('/download'))).toEqual([['/api/v1/podcast-episodes/2/download', { method: 'POST' }]])
+ })
+
+ it('removes a selected queue subset with bounded work and reloads the remaining rows', async () => {
+ const { wrapper, removeFromQueue, reload } = mountPanel()
+ wrapper.findComponent(PodcastQueueList).vm.$emit('toggleSelect', 1, true)
+ wrapper.findComponent(PodcastQueueList).vm.$emit('toggleSelect', 2, true)
+ await wrapper.vm.$nextTick()
+ await wrapper.get('[data-testid="podcast-bulk-remove-from-queue"]').trigger('click')
+ await flushPromises()
+
+ expect(removeFromQueue).toHaveBeenCalledTimes(2)
+ expect(reload).toHaveBeenCalledOnce()
+ })
+
+ it('hides download controls when the user lacks download permission', () => {
+ const { wrapper } = mountPanel({ canDownload: false })
+
+ expect(wrapper.text()).not.toContain('Download selected')
+ expect(wrapper.text()).not.toContain('Remove selected downloads')
+ })
+
+ it('offers route-level recovery actions for an empty queue', async () => {
+ const { wrapper } = mountPanel({ items: [], total: 0, selectionMode: false })
+ const buttons = wrapper.findAll('button')
+
+ await buttons.find((button) => button.text().includes('Browse episodes'))!.trigger('click')
+ await buttons.find((button) => button.text().includes('Browse shows'))!.trigger('click')
+
+ expect(wrapper.emitted('browse-episodes')).toHaveLength(1)
+ expect(wrapper.emitted('browse-shows')).toHaveLength(1)
+ })
+})
diff --git a/client/src/features/podcast/components/PodcastQueuePanel.vue b/client/src/features/podcast/components/PodcastQueuePanel.vue
new file mode 100644
index 000000000..2d1e99220
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastQueuePanel.vue
@@ -0,0 +1,310 @@
+
+
+
+
+
+ {{ t('podcast.labels.episodeCount', { count: formatNumber(total) }) }}
+ ·
+ {{ t('podcast.library.queueDuration', { duration: formatPodcastDuration(durationSeconds) }) }}
+
+
+ {{
+ bulkOutcome.failed > 0
+ ? t('podcast.messages.bulkQueueActionFailed', {
+ completed: formatNumber(bulkOutcome.completed),
+ failed: formatNumber(bulkOutcome.failed),
+ })
+ : t('podcast.messages.bulkQueueActionCompleted', { count: formatNumber(bulkOutcome.completed) })
+ }}
+
+
+ {{ t('podcast.library.reorderLoadedHint') }}
+
+
+
+
+ {{ t('podcast.library.clearSearch') }}
+
+ {{ t('podcast.library.browseEpisodes') }}
+ {{ t('podcast.library.browseShows') }}
+
+
+
+
+
+
+
+ {{ formatNumber(selectedItems.length) }}
+
+
+
+
+
+
+
+
+ {{ t('podcast.library.selectAllLoaded') }}
+
+
+
+
+
+
+
+ {{ t('podcast.actions.downloadSelected') }}
+
+
+
+
+
+
+
+ {{ t('podcast.actions.removeSelectedDownloads') }}
+
+
+
+
+
+
+
+ {{ t('podcast.actions.markSelectedPlayed') }}
+
+
+
+
+
+
+
+ {{ t('podcast.actions.removeSelectedFromQueue') }}
+
+
+
+
+
+
+
+
+ {{ t('components.selectionActionBar.exitSelection') }}
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastShortcutsDialog.vue b/client/src/features/podcast/components/PodcastShortcutsDialog.vue
new file mode 100644
index 000000000..9ce63c9cf
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastShortcutsDialog.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+ {{ t('podcast.shortcuts.title') }}
+ {{ t('podcast.shortcuts.description') }}
+
+
+
+ {{ group.label }}
+
+
+
{{ shortcut.label }}
+
+
+ {{ key }}
+
+
+
+
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastShowCard.spec.ts b/client/src/features/podcast/components/PodcastShowCard.spec.ts
new file mode 100644
index 000000000..eff607496
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastShowCard.spec.ts
@@ -0,0 +1,139 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import type { PodcastListItem } from '@bookorbit/types'
+import PodcastShowCard from './PodcastShowCard.vue'
+
+describe('PodcastShowCard', () => {
+ it('renders actionable episode counts, recency, archived state, and feed health', () => {
+ const wrapper = mount(PodcastShowCard, {
+ props: {
+ show: createShow({
+ unplayedCount: 12,
+ downloadedCount: 3,
+ latestPublishedAt: '2026-07-28T12:00:00.000Z',
+ consecutiveFailures: 2,
+ archivedAt: '2026-07-29T12:00:00.000Z',
+ }),
+ },
+ })
+
+ expect(wrapper.text()).toContain('12 new')
+ expect(wrapper.text()).not.toContain('12 unplayed')
+ expect(wrapper.text().match(/12 new/g)).toHaveLength(1)
+ expect(wrapper.text()).toContain('3')
+ expect(wrapper.text()).toContain('Jul 28, 2026')
+ expect(wrapper.text()).toContain('Feed failing')
+ expect(wrapper.text()).toContain('Archived')
+ })
+
+ it('keeps the feed failure badge off a local show, which is never refreshed', () => {
+ const wrapper = mount(PodcastShowCard, {
+ props: { show: createShow({ origin: 'local', consecutiveFailures: 4 }) },
+ })
+
+ expect(wrapper.text()).not.toContain('Feed failing')
+ expect(wrapper.text()).toContain('Local')
+ })
+
+ it('marks a local show whose folder left the disk, in place of the local badge', () => {
+ const wrapper = mount(PodcastShowCard, {
+ props: { show: createShow({ origin: 'local', missingAt: '2026-08-12T09:00:00.000Z' }) },
+ })
+
+ expect(wrapper.text()).toContain('Missing')
+ expect(wrapper.text()).not.toContain('Local')
+ })
+
+ it('leaves the missing badge off a show that is still on disk', () => {
+ const wrapper = mount(PodcastShowCard, { props: { show: createShow({ origin: 'local' }) } })
+
+ expect(wrapper.text()).not.toContain('Missing')
+ expect(wrapper.text()).toContain('Local')
+ })
+
+ it('picks the show instead of opening it while select mode is on', async () => {
+ const wrapper = mount(PodcastShowCard, { props: { show: createShow(), selectable: true } })
+
+ await wrapper.find('button[aria-label="Select The Long Orbit"]').trigger('click')
+
+ expect(wrapper.emitted('toggle-select')?.[0]?.[0]).toMatchObject({ id: 12 })
+ expect(wrapper.emitted('open')).toBeUndefined()
+ })
+
+ it('reports the pick state to assistive technology', () => {
+ const unselected = mount(PodcastShowCard, { props: { show: createShow(), selectable: true } })
+ const selected = mount(PodcastShowCard, { props: { show: createShow(), selectable: true, selected: true } })
+
+ expect(unselected.find('button[aria-label="Select The Long Orbit"]').attributes('aria-pressed')).toBe('false')
+ expect(selected.find('button[aria-label="Select The Long Orbit"]').attributes('aria-pressed')).toBe('true')
+ })
+
+ it('offers no row actions while selecting, so a pick cannot act by accident', () => {
+ const show = createShow({
+ followed: true,
+ playbackRecommendation: { episodeId: 55, title: 'Signals from Europa', positionSeconds: 0, durationSeconds: 1_800, kind: 'latest' },
+ })
+ const browsing = mount(PodcastShowCard, { props: { show } })
+ const selecting = mount(PodcastShowCard, { props: { show, selectable: true } })
+
+ expect(browsing.find('button[aria-label="Unfollow podcast"]').exists()).toBe(true)
+ expect(selecting.find('button[aria-label="Unfollow podcast"]').exists()).toBe(false)
+ expect(selecting.find('button[aria-label="Follow podcast"]').exists()).toBe(false)
+ expect(selecting.findAll('button[aria-label="Play latest episode Signals from Europa"]')).toHaveLength(0)
+ })
+
+ it('opens the show normally when select mode is off', async () => {
+ const wrapper = mount(PodcastShowCard, { props: { show: createShow() } })
+
+ await wrapper.find('button[aria-label="The Long Orbit"]').trigger('click')
+
+ expect(wrapper.emitted('open')?.[0]?.[0]).toMatchObject({ id: 12 })
+ expect(wrapper.emitted('toggle-select')).toBeUndefined()
+ })
+
+ it('uses a centered artwork action with a persistent touch fallback', async () => {
+ const wrapper = mount(PodcastShowCard, {
+ props: {
+ show: createShow({
+ playbackRecommendation: {
+ episodeId: 55,
+ title: 'Signals from Europa',
+ positionSeconds: 0,
+ durationSeconds: 1_800,
+ kind: 'latest',
+ },
+ }),
+ },
+ })
+
+ const playButtons = wrapper.findAll('button[aria-label="Play latest episode Signals from Europa"]')
+ expect(playButtons).toHaveLength(2)
+ expect(playButtons[0]!.classes()).toContain('size-[26cqi]')
+ expect(playButtons[1]!.classes()).toContain('[@media(hover:hover)]:hidden')
+
+ await playButtons[0]!.trigger('click')
+ expect(wrapper.emitted('play')?.[0]?.[0]).toMatchObject({ id: 12 })
+ })
+})
+
+function createShow(overrides: Partial = {}): PodcastListItem {
+ return {
+ id: 12,
+ libraryId: 7,
+ origin: 'feed',
+ title: 'The Long Orbit',
+ author: 'Orbit Audio',
+ imageUrl: null,
+ episodeCount: 42,
+ unplayedCount: 0,
+ downloadedCount: 0,
+ latestPublishedAt: null,
+ consecutiveFailures: 0,
+ playbackRecommendation: null,
+ followed: false,
+ notificationMode: 'off',
+ archivedAt: null,
+ missingAt: null,
+ ...overrides,
+ }
+}
diff --git a/client/src/features/podcast/components/PodcastShowCard.vue b/client/src/features/podcast/components/PodcastShowCard.vue
new file mode 100644
index 000000000..758053f6d
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastShowCard.vue
@@ -0,0 +1,191 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t('podcast.labels.newEpisodeCount', { count: formatNumber(show.unplayedCount) }) }}
+
+
+
+ {{ t('podcast.status.feedFailing') }}
+
+
+ {{ t('podcast.status.missing') }}
+
+
+
+ {{ t('podcast.status.localShow') }}
+
+ {{ t('podcast.status.archived') }}
+
+
+
+
+
+
+
+
+
+ {{ show.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ show.author || t('podcast.labels.unknownPublisher') }}
+
+
+ {{ formatNumber(show.episodeCount) }}
+
+ ·
+ {{ formatNumber(show.downloadedCount) }}
+ ·
+ {{ formatPodcastDate(show.latestPublishedAt) }}
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastShowManageSheet.spec.ts b/client/src/features/podcast/components/PodcastShowManageSheet.spec.ts
new file mode 100644
index 000000000..7ec25b7c6
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastShowManageSheet.spec.ts
@@ -0,0 +1,71 @@
+import { ref } from 'vue'
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
+import { makeListItem, makeShow } from '../test/fixtures'
+import { createShowManagementStub, sheetStubs, type PodcastShowManagementStub } from '../test/stubs'
+import type { PodcastShowManagement } from '../composables/usePodcastShowManagement'
+import PodcastShowManageSheet from './PodcastShowManageSheet.vue'
+
+const ARCHIVED_AT = '2026-08-01T00:00:00.000Z'
+
+function mountSheet(options: { management?: PodcastShowManagementStub; show?: ReturnType; canPurge?: boolean } = {}) {
+ const management = options.management ?? createShowManagementStub()
+ const wrapper = mount(PodcastShowManageSheet, {
+ props: {
+ management: management as unknown as PodcastShowManagement,
+ show: options.show ?? makeShow(),
+ canPurge: options.canPurge ?? true,
+ },
+ global: { stubs: sheetStubs() },
+ })
+ return { wrapper, management }
+}
+
+describe('PodcastShowManageSheet', () => {
+ it('enforces purge permission in the rendered controls', () => {
+ const { wrapper } = mountSheet({ canPurge: false })
+
+ expect(wrapper.find('[data-testid="podcast-delete"]').exists()).toBe(false)
+ expect(wrapper.text()).toContain('Only a library owner with permission to delete podcasts')
+ })
+
+ it('archives or restores through the management instance according to current state', async () => {
+ const active = mountSheet()
+ await active.wrapper.get('[data-testid="podcast-archive"]').trigger('click')
+ expect(active.management.archive).toHaveBeenCalledOnce()
+
+ const archived = mountSheet({ show: makeShow({ archivedAt: ARCHIVED_AT }) })
+ await archived.wrapper.get('[data-testid="podcast-restore"]').trigger('click')
+ expect(archived.management.restore).toHaveBeenCalledOnce()
+ })
+
+ it('keeps merge search and selection semantic', async () => {
+ const management = createShowManagementStub({ mergeCandidates: ref([makeListItem({ id: 14, title: 'Orbit Archive' })]) })
+ const { wrapper } = mountSheet({ management, show: makeShow({ archivedAt: ARCHIVED_AT }) })
+
+ await wrapper.get('input[type="search"]').setValue('orbit')
+ await wrapper.get('form').trigger('submit')
+ await wrapper
+ .findAll('button')
+ .find((button) => button.text().includes('Orbit Archive'))!
+ .trigger('click')
+
+ expect(management.searchMergeCandidates).toHaveBeenCalledWith('orbit')
+ expect(management.selectMergeCandidate).toHaveBeenCalledWith(expect.objectContaining({ id: 14 }))
+ })
+
+ it('owns typed delete and merge confirmation presentation', () => {
+ const management = createShowManagementStub({
+ selectedMergeSource: ref(makeListItem({ id: 14, title: 'Orbit Archive' })),
+ deletePreview: ref({ files: 2, bytes: 1024 }),
+ deleteDescription: ref('Delete four episodes?'),
+ deleteNeedsTypedConfirmation: ref(true),
+ })
+ const { wrapper } = mountSheet({ management, show: makeShow({ archivedAt: ARCHIVED_AT }) })
+ const dialogs = wrapper.findAllComponents(ConfirmDialog)
+
+ expect(dialogs[0]?.props()).toMatchObject({ open: true, description: 'Delete four episodes?', confirmationPhrase: 'Orbit Radio' })
+ expect(dialogs[1]?.props()).toMatchObject({ open: true, confirmationPhrase: 'Orbit Archive' })
+ })
+})
diff --git a/client/src/features/podcast/components/PodcastShowManageSheet.vue b/client/src/features/podcast/components/PodcastShowManageSheet.vue
new file mode 100644
index 000000000..a671d4324
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastShowManageSheet.vue
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
{{ t('podcast.show.dangerZone') }}
+
+
+ {{ t('podcast.show.restore') }}
+
+
+ {{ t('podcast.show.archive') }}
+
+
+ {{ t('podcast.show.deletePermanently') }}
+
+
+
+ {{ canPurge ? t('podcast.show.archiveOrDelete') : t('podcast.show.deleteRequiresOwner') }}
+
+
+
+
{{ t('podcast.show.mergeTitle') }}
+
{{ t('podcast.show.mergeDescription') }}
+
+
+ {{ t('podcast.actions.search') }}
+
+
+
+ {{ candidate.title }}
+ {{ candidate.author }}
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastShowSettingsSheet.spec.ts b/client/src/features/podcast/components/PodcastShowSettingsSheet.spec.ts
new file mode 100644
index 000000000..c3b851131
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastShowSettingsSheet.spec.ts
@@ -0,0 +1,103 @@
+import { defineComponent, h } from 'vue'
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import type { PodcastSummary } from '@bookorbit/types'
+import PodcastShowSettingsSheet from './PodcastShowSettingsSheet.vue'
+
+const passthrough = (tag: string) =>
+ defineComponent({
+ setup:
+ (_props, { slots }) =>
+ () =>
+ h(tag, slots.default?.()),
+ })
+
+function show(overrides: Partial = {}): PodcastSummary {
+ return {
+ id: 12,
+ libraryId: 7,
+ origin: 'feed',
+ title: 'Orbit Radio',
+ author: null,
+ description: null,
+ imageUrl: null,
+ siteUrl: null,
+ language: null,
+ podcastType: null,
+ explicit: false,
+ categories: [],
+ acquisitionPolicy: 'newest',
+ autoDownloadLimit: 5,
+ autoDownloadWindowDays: null,
+ downloadCleanup: 'keep',
+ downloadCleanupDelayHours: 24,
+ refreshIntervalMinutes: 120,
+ lockedFields: [],
+ artworkUpdatedAt: null,
+ episodeCount: 4,
+ unplayedCount: 4,
+ downloadedCount: 0,
+ playbackRecommendation: null,
+ followed: false,
+ notificationMode: 'off',
+ archivedAt: null,
+ missingAt: null,
+ lastRefreshAt: null,
+ lastRefreshSuccessAt: null,
+ feedSnapshotAt: null,
+ consecutiveFailures: 0,
+ nextRefreshAt: '2026-08-01T00:00:00.000Z',
+ createdAt: '2026-07-01T00:00:00.000Z',
+ updatedAt: '2026-07-01T00:00:00.000Z',
+ ...overrides,
+ }
+}
+
+function mountSheet(summary = show()) {
+ return mount(PodcastShowSettingsSheet, {
+ props: { open: true, show: summary, busy: false },
+ global: {
+ stubs: {
+ Sheet: passthrough('div'),
+ SheetContent: passthrough('div'),
+ SheetHeader: passthrough('div'),
+ SheetTitle: passthrough('h2'),
+ SheetDescription: passthrough('p'),
+ SheetFooter: passthrough('div'),
+ },
+ },
+ })
+}
+
+describe('PodcastShowSettingsSheet', () => {
+ it('initializes and emits the complete feed settings contract', async () => {
+ const wrapper = mountSheet()
+
+ await wrapper.get('[data-testid="podcast-save-settings"]').trigger('click')
+
+ expect(wrapper.emitted('save')?.[0]?.[0]).toEqual({
+ acquisitionPolicy: 'newest',
+ autoDownloadLimit: 5,
+ autoDownloadWindowDays: 30,
+ downloadCleanup: 'keep',
+ downloadCleanupDelayHours: 24,
+ refreshIntervalMinutes: 120,
+ })
+ })
+
+ it('hides feed-only controls for a local show while retaining cleanup settings', () => {
+ const wrapper = mountSheet(show({ origin: 'local' }))
+
+ expect(wrapper.text()).toContain('This show has no feed')
+ expect(wrapper.findComponent({ name: 'PodcastAcquisitionField' }).exists()).toBe(false)
+ expect(wrapper.find('[data-testid="podcast-download-cleanup"]').exists()).toBe(true)
+ })
+
+ it('hands destructive management back through a semantic event', async () => {
+ const wrapper = mountSheet()
+
+ await wrapper.get('[data-testid="podcast-open-manage"]').trigger('click')
+
+ expect(wrapper.emitted('manage')).toHaveLength(1)
+ })
+})
diff --git a/client/src/features/podcast/components/PodcastShowSettingsSheet.vue b/client/src/features/podcast/components/PodcastShowSettingsSheet.vue
new file mode 100644
index 000000000..127a7e238
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastShowSettingsSheet.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+ {{ t('podcast.show.localSettingsHint') }}
+
+
+
+ {{ t('podcast.cleanup.title') }}
+
+ {{ t('podcast.cleanup.keep') }}
+ {{ t('podcast.cleanup.afterFinished') }}
+
+ {{ cleanupDescription }}
+
+ {{ t('podcast.cleanup.delayHours') }}
+
+
+
+
+ {{ t('podcast.show.refreshInterval') }}
+
+
+
+
{{ t('podcast.show.manageActionsDescription') }}
+
+ {{ t('podcast.show.openManageActions') }}
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastShowsPanel.spec.ts b/client/src/features/podcast/components/PodcastShowsPanel.spec.ts
new file mode 100644
index 000000000..8aa1d4dc8
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastShowsPanel.spec.ts
@@ -0,0 +1,65 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import type { PodcastListItem } from '@bookorbit/types'
+import PodcastShowsPanel from './PodcastShowsPanel.vue'
+
+function show(): PodcastListItem {
+ return {
+ id: 4,
+ libraryId: 7,
+ origin: 'feed',
+ title: 'Orbit Radio',
+ author: null,
+ imageUrl: null,
+ episodeCount: 0,
+ unplayedCount: 0,
+ downloadedCount: 0,
+ latestPublishedAt: null,
+ consecutiveFailures: 0,
+ playbackRecommendation: null,
+ followed: false,
+ notificationMode: 'off',
+ archivedAt: null,
+ missingAt: null,
+ }
+}
+
+function mountPanel(overrides: Partial['$props']> = {}) {
+ return mount(PodcastShowsPanel, {
+ props: {
+ shows: [],
+ gridStyle: {},
+ refreshing: false,
+ importing: false,
+ hasSearchQuery: false,
+ canManageFeeds: true,
+ ...overrides,
+ },
+ })
+}
+
+describe('PodcastShowsPanel', () => {
+ it('does not expose feed mutations without feed-management permission', () => {
+ const wrapper = mountPanel({ canManageFeeds: false })
+
+ expect(wrapper.findAll('button').map((button) => button.text())).not.toEqual(expect.arrayContaining(['Add feed', 'Import OPML']))
+ })
+
+ it('shows import progress instead of the normal empty state while local discovery runs', () => {
+ const wrapper = mountPanel({ importing: true })
+
+ expect(wrapper.text()).toContain('Importing your podcasts')
+ expect(wrapper.text()).not.toContain('Ready for podcasts')
+ })
+
+ it('forwards show-card actions through semantic events', () => {
+ const wrapper = mountPanel({ shows: [show()] })
+ const card = wrapper.findComponent({ name: 'PodcastShowCard' })
+
+ card.vm.$emit('open', show())
+ card.vm.$emit('follow', show())
+
+ expect(wrapper.emitted('open')?.[0]?.[0]).toMatchObject({ id: 4 })
+ expect(wrapper.emitted('follow')?.[0]?.[0]).toMatchObject({ id: 4 })
+ })
+})
diff --git a/client/src/features/podcast/components/PodcastShowsPanel.vue b/client/src/features/podcast/components/PodcastShowsPanel.vue
new file mode 100644
index 000000000..b6726129c
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastShowsPanel.vue
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t('podcast.library.clearSearch') }}
+
+ {{ t('podcast.library.addFeed') }}
+ {{ t('podcast.library.importOpml') }}
+
+
+
+
+
diff --git a/client/src/features/podcast/components/PodcastStatusChip.spec.ts b/client/src/features/podcast/components/PodcastStatusChip.spec.ts
new file mode 100644
index 000000000..bb90500b3
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastStatusChip.spec.ts
@@ -0,0 +1,32 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import PodcastStatusChip, { type PodcastStatusKind } from './PodcastStatusChip.vue'
+
+describe('PodcastStatusChip', () => {
+ it.each<[PodcastStatusKind, string, string]>([
+ ['downloaded', 'Downloaded', 'text-success'],
+ ['downloadQueued', 'Download queued', 'text-warning'],
+ ['downloading', 'Downloading', 'text-info'],
+ ['failed', 'Download failed', 'text-destructive'],
+ ['unavailable', 'Unavailable', 'text-destructive'],
+ ['played', 'Played', 'text-success'],
+ ['removedFromFeed', 'No longer in feed', 'text-warning'],
+ ['nowPlaying', 'Now playing', 'text-primary'],
+ ['paused', 'Paused', 'text-foreground'],
+ ['inQueue', 'In queue', 'text-muted-foreground'],
+ ])('renders the %s status with an icon and semantic color', (kind, label, colorClass) => {
+ const wrapper = mount(PodcastStatusChip, { props: { kind } })
+
+ expect(wrapper.text()).toContain(label)
+ expect(wrapper.get('[data-testid="podcast-status-icon"]').attributes('data-testid')).toBe('podcast-status-icon')
+ expect(wrapper.get('span').classes()).toContain(colorClass)
+ })
+
+ it('uses an icon-only trigger with accessible tooltip text in compact mode', () => {
+ const wrapper = mount(PodcastStatusChip, { props: { kind: 'downloaded', compact: true } })
+
+ expect(wrapper.get('.sr-only').text()).toBe('Downloaded')
+ expect(wrapper.get('[data-testid="podcast-status-icon"]').attributes('data-testid')).toBe('podcast-status-icon')
+ expect(wrapper.get('span').text()).toBe('Downloaded')
+ })
+})
diff --git a/client/src/features/podcast/components/PodcastStatusChip.vue b/client/src/features/podcast/components/PodcastStatusChip.vue
new file mode 100644
index 000000000..e84a35b0b
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastStatusChip.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+ {{ status.label }}
+
+
+ {{ status.label }}
+
+
+
+
+ {{ status.label }}
+
+
diff --git a/client/src/features/podcast/components/PodcastViewHeader.vue b/client/src/features/podcast/components/PodcastViewHeader.vue
new file mode 100644
index 000000000..6cd1a5819
--- /dev/null
+++ b/client/src/features/podcast/components/PodcastViewHeader.vue
@@ -0,0 +1,287 @@
+
+
+
+
+
+
+
+
{{ title }}
+
+
{{ t('podcast.library.loading') }}
+
+
+
+
+
+
+
+
+
+ {{ t('podcast.library.addFeed') }}
+
+
+
+
+
+
+
+
+ {{ view.label }}
+ {{ formatNumber(total) }}
+
+
+
+
+
+
+
+
+
+
+ {{ activeSortLabel }}
+ {{ t('podcast.sort.label') }}
+
+
+
+ {{ sortLabel }}
+
+
+ {{ option.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ hasUnhealthyFeeds ? t('podcast.library.feedsNeedAttention') : t('podcast.library.feedHealth') }}
+
+
+
+
+ {{ t('podcast.library.settings') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('components.viewHeader.display') }}
+ {{ t('components.viewHeader.displayDescription') }}
+
+
+
+
+
+
+
diff --git a/client/src/features/podcast/composables/createLockedMetadataForm.ts b/client/src/features/podcast/composables/createLockedMetadataForm.ts
new file mode 100644
index 000000000..8df98edc4
--- /dev/null
+++ b/client/src/features/podcast/composables/createLockedMetadataForm.ts
@@ -0,0 +1,130 @@
+import { computed, reactive, ref, type Ref } from 'vue'
+import { apiJson, jsonBody } from '@/lib/api-json'
+
+/** Both metadata endpoints answer with the stored record, including the lock set they just wrote. */
+export interface LockedMetadataRecord {
+ lockedFields: string[]
+}
+
+export interface LockedMetadataFormConfig<
+ Form extends object,
+ Field extends string,
+ LockedField extends string,
+ Loaded extends LockedMetadataRecord,
+> {
+ emptyForm: () => Form
+ /** The fields the form renders a control for, in the order the payload is built. */
+ fields: readonly Field[]
+ /** Every lockable field, including ones the form has no control for yet. */
+ lockable: readonly LockedField[]
+ /** Copies a loaded record onto the form. The lock set is read off the record separately. */
+ apply: (form: Form, record: Loaded) => void
+ /** The comparable projection of one field; anything non-primitive is compared by its JSON. */
+ normalize: (field: Field, form: Form) => unknown
+ /** The value the API expects for one changed field. The field id doubles as the payload key. */
+ toPayload: (field: Field, form: Form) => unknown
+ endpoint: (id: number) => string
+ fallbackKey: string
+}
+
+/**
+ * The half of a metadata editor that is the same for shows and episodes: a snapshot diff against
+ * the loaded record, the lock set with its optimistic `willLock` preview, a sparse PATCH built from
+ * only the changed fields, and the save that re-snapshots from the server's answer.
+ *
+ * Callers add what differs: which fields exist, how each is normalized and serialized, and any
+ * field-specific derived state.
+ */
+export function createLockedMetadataForm<
+ Form extends object,
+ Field extends string,
+ LockedField extends string,
+ Loaded extends LockedMetadataRecord,
+ Payload extends object,
+>(config: LockedMetadataFormConfig) {
+ const form = reactive(config.emptyForm()) as Form
+ const lockedFields = ref([]) as Ref
+ const saving = ref(false)
+ const error = ref(null)
+ const snapshot = ref(JSON.stringify(form))
+ const lockSnapshot = ref('[]')
+
+ const locksChanged = computed(() => JSON.stringify(lockedFields.value) !== lockSnapshot.value)
+ const isDirty = computed(() => JSON.stringify(form) !== snapshot.value || locksChanged.value)
+ const changedFields = computed(() => {
+ const previous = JSON.parse(snapshot.value) as Form
+ return config.fields.filter((field) => JSON.stringify(config.normalize(field, form)) !== JSON.stringify(config.normalize(field, previous)))
+ })
+ /** Whitespace-only edits leave nothing to send, so dirtiness alone cannot decide whether to save. */
+ const hasChanges = computed(() => changedFields.value.length > 0 || locksChanged.value)
+
+ function load(record: Loaded) {
+ config.apply(form, record)
+ lockedFields.value = config.lockable.filter((field) => (record.lockedFields ?? []).includes(field))
+ snapshot.value = JSON.stringify(form)
+ lockSnapshot.value = JSON.stringify(lockedFields.value)
+ error.value = null
+ }
+
+ function reset() {
+ Object.assign(form, JSON.parse(snapshot.value) as Form)
+ lockedFields.value = JSON.parse(lockSnapshot.value) as LockedField[]
+ error.value = null
+ }
+
+ function isLocked(field: LockedField): boolean {
+ return lockedFields.value.includes(field)
+ }
+
+ /** A changed field is shown as locked before saving, because the server locks it on write. */
+ function willLock(field: LockedField): boolean {
+ return !isLocked(field) && (config.fields as readonly string[]).includes(field) && (changedFields.value as readonly string[]).includes(field)
+ }
+
+ function toggleLock(field: LockedField) {
+ lockedFields.value = isLocked(field)
+ ? lockedFields.value.filter((locked) => locked !== field)
+ : config.lockable.filter((locked) => locked === field || isLocked(locked))
+ }
+
+ function buildPayload(): Payload {
+ const payload: Record = {}
+ for (const field of changedFields.value) payload[field] = config.toPayload(field, form)
+ if (locksChanged.value) payload.lockedFields = [...lockedFields.value]
+ return payload as Payload
+ }
+
+ async function save(id: number): Promise {
+ const payload = buildPayload()
+ if (Object.keys(payload).length === 0) return null
+ saving.value = true
+ error.value = null
+ try {
+ const result = await apiJson(config.endpoint(id), jsonBody('PATCH', payload), config.fallbackKey)
+ load(result)
+ return result
+ } catch (reason) {
+ error.value = reason instanceof Error ? reason.message : null
+ return null
+ } finally {
+ saving.value = false
+ }
+ }
+
+ return {
+ form,
+ lockedFields,
+ saving,
+ error,
+ isDirty,
+ hasChanges,
+ changedFields,
+ load,
+ reset,
+ isLocked,
+ willLock,
+ toggleLock,
+ buildPayload,
+ save,
+ }
+}
diff --git a/client/src/features/podcast/composables/podcast-audio-engine.spec.ts b/client/src/features/podcast/composables/podcast-audio-engine.spec.ts
new file mode 100644
index 000000000..40a4e2eff
--- /dev/null
+++ b/client/src/features/podcast/composables/podcast-audio-engine.spec.ts
@@ -0,0 +1,182 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { registerAudioFocusOwner, releaseAudioFocusOwner, requestAudioFocus } from '@/lib/audio-focus'
+import { stubBufferedRanges } from '../test/stubs'
+import { createPodcastAudioEngine, type PodcastAudioEngineLoadOptions } from './podcast-audio-engine'
+
+interface MockHowlOptions {
+ onload?: () => void
+ onplay?: () => void
+ onpause?: () => void
+ onstop?: () => void
+ onend?: () => void
+ onloaderror?: () => void
+ onplayerror?: () => void
+}
+
+interface MockHowlInstance {
+ options: MockHowlOptions
+ currentPosition: number
+ active: boolean
+ unloaded: boolean
+ _sounds: Array<{ _node: HTMLAudioElement }>
+}
+
+const howlInstances = vi.hoisted(() => [] as MockHowlInstance[])
+
+vi.mock('howler', () => ({
+ Howl: class {
+ currentPosition = 0
+ active = false
+ unloaded = false
+ _sounds = [{ _node: document.createElement('audio') }]
+
+ constructor(public options: MockHowlOptions) {
+ howlInstances.push(this)
+ }
+
+ duration() {
+ return 300
+ }
+
+ seek(value?: number) {
+ if (typeof value === 'number') this.currentPosition = value
+ return this.currentPosition
+ }
+
+ playing() {
+ return this.active
+ }
+
+ play() {
+ this.active = true
+ this.options.onplay?.()
+ }
+
+ pause() {
+ this.active = false
+ this.options.onpause?.()
+ }
+
+ rate() {}
+ volume() {}
+
+ unload() {
+ this.active = false
+ this.unloaded = true
+ }
+ },
+}))
+
+describe('createPodcastAudioEngine', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ howlInstances.length = 0
+ })
+
+ afterEach(() => {
+ releaseAudioFocusOwner('tts')
+ vi.useRealTimers()
+ })
+
+ it('keeps one Howl active and ignores callbacks from the disposed engine', () => {
+ const engine = createPodcastAudioEngine()
+ const first = loadOptions()
+ const second = loadOptions()
+
+ engine.load(first)
+ const stale = howlInstances[0]!
+ engine.load(second)
+ stale.options.onload?.()
+
+ expect(stale.unloaded).toBe(true)
+ expect(howlInstances).toHaveLength(2)
+ expect(first.onLoad).not.toHaveBeenCalled()
+ howlInstances[1]!.options.onload?.()
+ expect(second.onLoad).toHaveBeenCalledWith(300)
+ })
+
+ it('claims audio focus and reports playback positions on one ticker', async () => {
+ const pauseTts = vi.fn<() => void>()
+ registerAudioFocusOwner('tts', pauseTts)
+ const engine = createPodcastAudioEngine()
+ const options = loadOptions()
+ engine.load(options)
+ const howl = howlInstances[0]!
+ howl.currentPosition = 75
+
+ engine.play()
+ await vi.advanceTimersByTimeAsync(500)
+
+ expect(pauseTts).toHaveBeenCalledOnce()
+ expect(options.onPlay).toHaveBeenCalledOnce()
+ expect(options.onTick).toHaveBeenCalledWith(75)
+ expect(vi.getTimerCount()).toBe(1)
+
+ engine.pause()
+ expect(options.onPause).toHaveBeenCalledOnce()
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it('detaches buffering listeners and ranges from the previous media element', () => {
+ const engine = createPodcastAudioEngine()
+ engine.load(loadOptions())
+ const first = howlInstances[0]!
+ engine.play()
+ first._sounds[0]!._node.dispatchEvent(new Event('waiting'))
+ expect(engine.isBuffering.value).toBe(true)
+
+ engine.load(loadOptions())
+ first._sounds[0]!._node.dispatchEvent(new Event('waiting'))
+
+ expect(engine.isBuffering.value).toBe(false)
+ expect(engine.bufferedRanges.value).toEqual([])
+ })
+
+ it('mirrors buffered ranges without replacing an unchanged snapshot', () => {
+ const engine = createPodcastAudioEngine()
+ engine.load(loadOptions())
+ const node = howlInstances[0]!._sounds[0]!._node
+ stubBufferedRanges(node, [{ start: 0, end: 120 }])
+ engine.play()
+
+ expect(engine.bufferedRanges.value).toEqual([{ start: 0, end: 120 }])
+ const current = engine.bufferedRanges.value
+ node.dispatchEvent(new Event('progress'))
+ expect(engine.bufferedRanges.value).toBe(current)
+ })
+
+ // A disposed engine holds nothing to pause, so leaving it registered would let another player's
+ // focus request call into a Howl that is already unloaded.
+ it('holds audio-focus ownership only between load and dispose', () => {
+ const engine = createPodcastAudioEngine()
+ const pauseTts = vi.fn<() => void>()
+
+ engine.load(loadOptions())
+ engine.dispose()
+ registerAudioFocusOwner('tts', pauseTts)
+ requestAudioFocus('tts')
+
+ expect(howlInstances[0]!.active).toBe(false)
+
+ engine.load(loadOptions())
+ engine.play()
+ expect(pauseTts).toHaveBeenCalledOnce()
+ })
+})
+
+function loadOptions(): PodcastAudioEngineLoadOptions {
+ return {
+ src: '/stream',
+ format: 'mp3',
+ rate: 1,
+ volume: 1,
+ onLoad: vi.fn<(duration: number) => void>(),
+ onPlay: vi.fn<() => void>(),
+ onPause: vi.fn<() => void>(),
+ onStop: vi.fn<() => void>(),
+ onEnd: vi.fn<() => void>(),
+ onLoadError: vi.fn<() => void>(),
+ onPlayError: vi.fn<() => void>(),
+ onTick: vi.fn<(position: number) => void>(),
+ }
+}
diff --git a/client/src/features/podcast/composables/podcast-audio-engine.ts b/client/src/features/podcast/composables/podcast-audio-engine.ts
new file mode 100644
index 000000000..639473ae9
--- /dev/null
+++ b/client/src/features/podcast/composables/podcast-audio-engine.ts
@@ -0,0 +1,224 @@
+import { ref } from 'vue'
+import { Howl } from 'howler'
+import { registerAudioFocusOwner, releaseAudioFocusOwner, requestAudioFocus } from '@/lib/audio-focus'
+
+const TICK_INTERVAL_MS = 500
+
+export interface PodcastAudioEngineLoadOptions {
+ src: string
+ format: string | null
+ rate: number
+ volume: number
+ onLoad: (duration: number) => void
+ onPlay: () => void
+ onPause: () => void
+ onStop: () => void
+ onEnd: () => void
+ onLoadError: () => void
+ onPlayError: () => void
+ onTick: (position: number) => void
+}
+
+export function createPodcastAudioEngine() {
+ const isBuffering = ref(false)
+ const bufferedRanges = ref>([])
+ let howl: Howl | null = null
+ let bufferingNode: HTMLAudioElement | null = null
+ let ticker: ReturnType | null = null
+ let tickHandler: ((position: number) => void) | null = null
+
+ /**
+ * Focus ownership is claimed when there is something to pause and released when there is not, so a
+ * disposed engine cannot be asked to pause audio it no longer holds.
+ */
+ function init() {
+ registerAudioFocusOwner('podcast', pause)
+ }
+
+ function load(options: PodcastAudioEngineLoadOptions) {
+ dispose()
+ init()
+ const activeHowl = new Howl({
+ src: [options.src],
+ ...(options.format ? { format: [options.format] } : {}),
+ html5: true,
+ preload: true,
+ rate: options.rate,
+ volume: options.volume,
+ onload() {
+ if (howl !== activeHowl) return
+ options.onLoad(activeHowl.duration())
+ },
+ onplay() {
+ if (howl !== activeHowl) return
+ requestAudioFocus('podcast')
+ attachBufferingListeners(activeHowl)
+ options.onPlay()
+ startTicker(options.onTick)
+ },
+ onpause() {
+ if (howl !== activeHowl) return
+ stopTicker()
+ clearBuffering()
+ options.onPause()
+ },
+ onstop() {
+ if (howl !== activeHowl) return
+ stopTicker()
+ clearBuffering()
+ options.onStop()
+ },
+ onend() {
+ if (howl !== activeHowl) return
+ stopTicker()
+ clearBuffering()
+ options.onEnd()
+ },
+ onloaderror() {
+ if (howl !== activeHowl) return
+ stopTicker()
+ clearBuffering()
+ options.onLoadError()
+ },
+ onplayerror() {
+ if (howl !== activeHowl) return
+ stopTicker()
+ clearBuffering()
+ options.onPlayError()
+ },
+ })
+ howl = activeHowl
+ }
+
+ function play() {
+ howl?.play()
+ }
+
+ function pause() {
+ howl?.pause()
+ }
+
+ function togglePlayback() {
+ if (!howl) return
+ if (howl.playing()) howl.pause()
+ else howl.play()
+ }
+
+ function seek(seconds: number) {
+ howl?.seek(seconds)
+ }
+
+ function position(fallback = 0): number {
+ const current = howl?.seek()
+ return typeof current === 'number' ? current : fallback
+ }
+
+ function setRate(rate: number) {
+ howl?.rate(rate)
+ }
+
+ function setVolume(volume: number) {
+ howl?.volume(volume)
+ }
+
+ function playing(): boolean {
+ return howl?.playing() ?? false
+ }
+
+ function loaded(): boolean {
+ return howl !== null
+ }
+
+ function dispose() {
+ stopTicker()
+ detachBufferingListeners()
+ if (howl) howl.unload()
+ howl = null
+ releaseAudioFocusOwner('podcast')
+ }
+
+ function startTicker(handler: (position: number) => void) {
+ stopTicker()
+ tickHandler = handler
+ ticker = setInterval(() => {
+ readBufferedRanges()
+ tickHandler?.(position())
+ }, TICK_INTERVAL_MS)
+ }
+
+ function stopTicker() {
+ if (ticker) clearInterval(ticker)
+ ticker = null
+ tickHandler = null
+ }
+
+ function markBuffering() {
+ isBuffering.value = true
+ }
+
+ function clearBuffering() {
+ isBuffering.value = false
+ }
+
+ function readBufferedRanges() {
+ const node = bufferingNode
+ if (!node) {
+ if (bufferedRanges.value.length > 0) bufferedRanges.value = []
+ return
+ }
+ const next: Array<{ start: number; end: number }> = []
+ const buffered = node.buffered
+ for (let index = 0; index < buffered.length; index++) next.push({ start: buffered.start(index), end: buffered.end(index) })
+ const current = bufferedRanges.value
+ const unchanged =
+ next.length === current.length && next.every((range, index) => range.start === current[index]!.start && range.end === current[index]!.end)
+ if (!unchanged) bufferedRanges.value = next
+ }
+
+ function attachBufferingListeners(activeHowl: Howl) {
+ detachBufferingListeners()
+ const node = resolveAudioNode(activeHowl)
+ if (!node) return
+ bufferingNode = node
+ node.addEventListener('waiting', markBuffering)
+ node.addEventListener('playing', clearBuffering)
+ node.addEventListener('canplay', clearBuffering)
+ node.addEventListener('progress', readBufferedRanges)
+ readBufferedRanges()
+ }
+
+ function detachBufferingListeners() {
+ if (bufferingNode) {
+ bufferingNode.removeEventListener('waiting', markBuffering)
+ bufferingNode.removeEventListener('playing', clearBuffering)
+ bufferingNode.removeEventListener('canplay', clearBuffering)
+ bufferingNode.removeEventListener('progress', readBufferedRanges)
+ bufferingNode = null
+ }
+ isBuffering.value = false
+ bufferedRanges.value = []
+ }
+
+ return {
+ isBuffering,
+ bufferedRanges,
+ init,
+ load,
+ play,
+ pause,
+ togglePlayback,
+ seek,
+ position,
+ setRate,
+ setVolume,
+ playing,
+ loaded,
+ dispose,
+ }
+}
+
+function resolveAudioNode(activeHowl: Howl): HTMLAudioElement | null {
+ const sounds = (activeHowl as unknown as { _sounds?: Array<{ _node?: unknown } | undefined> })._sounds
+ const node = sounds?.[0]?._node
+ return node instanceof HTMLAudioElement ? node : null
+}
diff --git a/client/src/features/podcast/composables/podcast-list-lane.ts b/client/src/features/podcast/composables/podcast-list-lane.ts
new file mode 100644
index 000000000..e14fbb3b0
--- /dev/null
+++ b/client/src/features/podcast/composables/podcast-list-lane.ts
@@ -0,0 +1,71 @@
+import { computed, ref, type Ref } from 'vue'
+import { i18n } from '@/i18n'
+import { ApiError } from '@/lib/api-json'
+import { createRequestGeneration } from '@/lib/async'
+
+export interface PodcastListLaneOptions {
+ /** Reads one page. `page` is 0-based, as everywhere else in the app. */
+ fetchPage: (page: number, size: number) => Promise<{ items: TItem[]; total: number }>
+ /** Localized copy for a failure the server did not name with an error code. */
+ fallbackKey: string
+}
+
+/**
+ * One paged list with its own loading, error and request token. The library page shows four of
+ * these, and they used to share a single set: loading the queue tab silently cancelled an episodes
+ * request that was still open, and a failure on one lane painted an error over another.
+ */
+export function createPodcastListLane(options: PodcastListLaneOptions) {
+ const items = ref([]) as Ref
+ const total = ref(0)
+ const page = ref(0)
+ const loading = ref(false)
+ const loadingMore = ref(false)
+ const error = ref(null)
+ /** Whether this lane has ever answered, which is what tells an empty list from an unloaded one. */
+ const initialized = ref(false)
+ const generation = createRequestGeneration()
+
+ const hasMore = computed(() => initialized.value && items.value.length < total.value)
+ const busy = computed(() => loading.value || loadingMore.value)
+
+ async function load(append = false, size = 50): Promise {
+ const requestedPage = append ? page.value + 1 : 0
+ const activeGeneration = generation.begin()
+ if (append) loadingMore.value = true
+ else loading.value = true
+ error.value = null
+ try {
+ const result = await options.fetchPage(requestedPage, size)
+ if (!generation.isCurrent(activeGeneration)) return
+ items.value = append ? [...items.value, ...result.items] : result.items
+ page.value = requestedPage
+ total.value = result.total
+ initialized.value = true
+ } catch (reason) {
+ if (!generation.isCurrent(activeGeneration)) return
+ error.value = reason instanceof ApiError ? reason.message : i18n.global.t(options.fallbackKey)
+ } finally {
+ if (generation.isCurrent(activeGeneration)) {
+ if (append) loadingMore.value = false
+ else loading.value = false
+ }
+ }
+ }
+
+ /** Drops everything and invalidates any open request, for a library switch. */
+ function reset(): void {
+ generation.invalidate()
+ items.value = []
+ total.value = 0
+ page.value = 0
+ loading.value = false
+ loadingMore.value = false
+ error.value = null
+ initialized.value = false
+ }
+
+ return { items, total, page, loading, loadingMore, busy, error, initialized, hasMore, load, reset }
+}
+
+export type PodcastListLane = ReturnType>
diff --git a/client/src/features/podcast/composables/podcast-media-session.spec.ts b/client/src/features/podcast/composables/podcast-media-session.spec.ts
new file mode 100644
index 000000000..fecf69e3a
--- /dev/null
+++ b/client/src/features/podcast/composables/podcast-media-session.spec.ts
@@ -0,0 +1,147 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastEpisodeSummary } from '@bookorbit/types'
+import { createPodcastMediaSession } from './podcast-media-session'
+
+describe('createPodcastMediaSession', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ Reflect.deleteProperty(navigator, 'mediaSession')
+ })
+
+ it('registers metadata and transport actions, including validated seeks', () => {
+ const session = stubMediaSession()
+ const actions = createActions()
+ const mediaSession = createPodcastMediaSession()
+
+ mediaSession.updateEpisode(episode(), actions)
+ session.handlers.get('seekto')?.({ action: 'seekto', seekTime: 75, fastSeek: true })
+ session.handlers.get('seekto')?.({ action: 'seekto' })
+ session.handlers.get('nexttrack')?.({ action: 'nexttrack' })
+
+ expect(session.metadata).toMatchObject({
+ title: 'First episode',
+ artist: 'Orbit Radio',
+ artwork: [{ src: 'https://feed.example/art.png?size=large', sizes: '512x512', type: 'image/png' }],
+ })
+ expect(actions.seekTo).toHaveBeenCalledExactlyOnceWith(75)
+ expect(actions.next).toHaveBeenCalledOnce()
+ })
+
+ it('updates playback and bounded position state before clearing every handler', () => {
+ const session = stubMediaSession()
+ const mediaSession = createPodcastMediaSession()
+ mediaSession.updateEpisode(episode(), createActions())
+
+ mediaSession.updatePlaybackState(true)
+ mediaSession.updatePositionState({ duration: 300, playbackRate: 1.5, currentTime: 450 })
+ mediaSession.clear()
+
+ expect(session.positionStates).toEqual([{ duration: 300, playbackRate: 1.5, position: 300 }, null])
+ expect(session.playbackState).toBe('none')
+ expect(session.metadata).toBeNull()
+ expect([...session.handlers.values()]).toEqual(Array(8).fill(null))
+ })
+
+ it('tolerates unavailable and unsupported Media Session actions', () => {
+ const mediaSession = createPodcastMediaSession()
+ expect(() => mediaSession.clear()).not.toThrow()
+
+ const session = stubMediaSession(new Set(['seekto']))
+ expect(() => mediaSession.updateEpisode(episode(), createActions())).not.toThrow()
+ expect(session.handlers.has('play')).toBe(true)
+ expect(session.handlers.has('seekto')).toBe(false)
+ })
+})
+
+function createActions() {
+ return {
+ play: vi.fn<() => void>(),
+ pause: vi.fn<() => void>(),
+ seekBackward: vi.fn<() => void>(),
+ seekForward: vi.fn<() => void>(),
+ seekTo: vi.fn<(seconds: number) => void>(),
+ stop: vi.fn<() => void>(),
+ previous: vi.fn<() => void>(),
+ next: vi.fn<() => void>(),
+ }
+}
+
+interface StubbedMediaSession {
+ handlers: Map
+ metadata: MediaMetadataInit | null
+ playbackState: MediaSessionPlaybackState
+ positionStates: Array
+}
+
+function stubMediaSession(unsupported = new Set()): StubbedMediaSession {
+ const stub: StubbedMediaSession = { handlers: new Map(), metadata: null, playbackState: 'none', positionStates: [] }
+ vi.stubGlobal(
+ 'MediaMetadata',
+ class {
+ constructor(init: MediaMetadataInit) {
+ Object.assign(this, init)
+ }
+ },
+ )
+ Object.defineProperty(navigator, 'mediaSession', {
+ configurable: true,
+ value: {
+ get playbackState() {
+ return stub.playbackState
+ },
+ set playbackState(value: MediaSessionPlaybackState) {
+ stub.playbackState = value
+ },
+ get metadata() {
+ return stub.metadata
+ },
+ set metadata(value: MediaMetadataInit | null) {
+ stub.metadata = value
+ },
+ setActionHandler(action: MediaSessionAction, handler: MediaSessionActionHandler | null) {
+ if (unsupported.has(action)) throw new DOMException('Unsupported action')
+ stub.handlers.set(action, handler)
+ },
+ setPositionState(position?: MediaPositionState) {
+ stub.positionStates.push(position ?? null)
+ },
+ },
+ })
+ return stub
+}
+
+function episode(): PodcastEpisodeSummary {
+ return {
+ id: 42,
+ libraryId: 2,
+ origin: 'feed',
+ podcastId: 3,
+ podcastTitle: 'Orbit Radio',
+ podcastImageUrl: 'https://feed.example/art.png?size=large',
+ title: 'First episode',
+ subtitle: null,
+ description: null,
+ publishedAt: null,
+ season: null,
+ episode: null,
+ episodeType: null,
+ durationSeconds: 300,
+ audioFormat: 'mp3',
+ explicit: false,
+ chapters: [],
+ transcripts: [],
+ lockedFields: [],
+ inFeed: true,
+ mediaStatus: 'remote',
+ localSizeBytes: null,
+ checksum: null,
+ positionSeconds: 30,
+ progressPercent: 10,
+ finished: false,
+ pinned: false,
+ queued: false,
+ lastListenedAt: null,
+ createdAt: '2026-07-10T00:00:00.000Z',
+ updatedAt: '2026-07-10T00:00:00.000Z',
+ }
+}
diff --git a/client/src/features/podcast/composables/podcast-media-session.ts b/client/src/features/podcast/composables/podcast-media-session.ts
new file mode 100644
index 000000000..37b596d78
--- /dev/null
+++ b/client/src/features/podcast/composables/podcast-media-session.ts
@@ -0,0 +1,110 @@
+import type { PodcastEpisodeSummary } from '@bookorbit/types'
+
+const MEDIA_SESSION_ACTIONS = ['play', 'pause', 'seekbackward', 'seekforward', 'seekto', 'stop', 'previoustrack', 'nexttrack'] as const
+const ARTWORK_SIZES = '512x512'
+const ARTWORK_MIME_TYPES: Record = {
+ avif: 'image/avif',
+ gif: 'image/gif',
+ jpeg: 'image/jpeg',
+ jpg: 'image/jpeg',
+ png: 'image/png',
+ webp: 'image/webp',
+}
+
+export interface PodcastMediaSessionActions {
+ play: () => void
+ pause: () => void
+ seekBackward: () => void
+ seekForward: () => void
+ seekTo: (seconds: number) => void
+ stop: () => void
+ previous: () => void
+ next: () => void
+}
+
+export interface PodcastMediaPosition {
+ duration: number
+ playbackRate: number
+ currentTime: number
+}
+
+export function createPodcastMediaSession() {
+ function updateEpisode(episode: PodcastEpisodeSummary, actions: PodcastMediaSessionActions) {
+ const mediaSession = getMediaSession()
+ if (!mediaSession) return
+ mediaSession.metadata = new MediaMetadata({
+ title: episode.title,
+ artist: episode.podcastTitle,
+ artwork: artworkEntries(episode.podcastImageUrl),
+ })
+ setAction('play', actions.play)
+ setAction('pause', actions.pause)
+ setAction('seekbackward', actions.seekBackward)
+ setAction('seekforward', actions.seekForward)
+ setAction('seekto', (details) => {
+ if (typeof details.seekTime === 'number' && Number.isFinite(details.seekTime)) actions.seekTo(details.seekTime)
+ })
+ setAction('stop', actions.stop)
+ setAction('previoustrack', actions.previous)
+ setAction('nexttrack', actions.next)
+ }
+
+ function updatePlaybackState(isPlaying: boolean) {
+ const mediaSession = getMediaSession()
+ if (mediaSession) mediaSession.playbackState = isPlaying ? 'playing' : 'paused'
+ }
+
+ function updatePositionState(position: PodcastMediaPosition) {
+ const mediaSession = getMediaSession()
+ if (!mediaSession?.setPositionState) return
+ if (position.duration <= 0) {
+ clearPositionState(mediaSession)
+ return
+ }
+ mediaSession.setPositionState({
+ duration: position.duration,
+ playbackRate: position.playbackRate,
+ position: Math.min(position.currentTime, position.duration),
+ })
+ }
+
+ function clear() {
+ const mediaSession = getMediaSession()
+ if (!mediaSession) return
+ mediaSession.metadata = null
+ mediaSession.playbackState = 'none'
+ clearPositionState(mediaSession)
+ for (const action of MEDIA_SESSION_ACTIONS) setAction(action, null)
+ }
+
+ function setAction(action: MediaSessionAction, handler: MediaSessionActionHandler | null) {
+ const mediaSession = getMediaSession()
+ if (!mediaSession) return
+ try {
+ mediaSession.setActionHandler(action, handler)
+ } catch {
+ return
+ }
+ }
+
+ return { updateEpisode, updatePlaybackState, updatePositionState, clear }
+}
+
+function clearPositionState(mediaSession: MediaSession) {
+ try {
+ mediaSession.setPositionState?.()
+ } catch {
+ return
+ }
+}
+
+function getMediaSession(): MediaSession | null {
+ return typeof navigator !== 'undefined' && 'mediaSession' in navigator ? navigator.mediaSession : null
+}
+
+function artworkEntries(imageUrl: string | null): MediaImage[] {
+ if (!imageUrl) return []
+ const extension = imageUrl.split(/[?#]/)[0]?.split('.').pop()?.toLowerCase() ?? ''
+ const type = ARTWORK_MIME_TYPES[extension]
+ return [{ src: imageUrl, sizes: ARTWORK_SIZES, ...(type ? { type } : {}) }]
+}
diff --git a/client/src/features/podcast/composables/podcast-player-actions.ts b/client/src/features/podcast/composables/podcast-player-actions.ts
new file mode 100644
index 000000000..3be676a5a
--- /dev/null
+++ b/client/src/features/podcast/composables/podcast-player-actions.ts
@@ -0,0 +1,123 @@
+import type { PodcastBookmark, PodcastEpisodeSummary } from '@bookorbit/types'
+import { apiJson, apiSend, jsonBody } from '@/lib/api-json'
+import { i18n } from '@/i18n'
+import { formatPlaybackClock } from '../lib/podcast-format'
+import { removeEpisodeDownload, requestEpisodeDownload, updateEpisodeState } from '../lib/podcast-episode-api'
+import type { createPodcastPlayerState } from './podcast-player-state'
+
+type PodcastPlayerState = ReturnType
+
+export interface PodcastPlayerActionDeps {
+ state: PodcastPlayerState
+ /** The player's episode-load token, read fresh so a swap mid-request is visible. */
+ currentGeneration: () => number
+ currentPosition: () => number
+ seekTo: (seconds: number) => void
+ onDownloadRequested: () => void
+}
+
+/**
+ * Everything the player does *to* the episode it has loaded: progress, pinning, bookmarks and the
+ * local file.
+ *
+ * Each one captures its episode before awaiting the server. The player is shared state, so a switch
+ * while the request is open would otherwise stamp the previous episode's values, or a bookmark
+ * belonging to it, onto whatever is loaded now.
+ */
+export function createPodcastPlayerActions(deps: PodcastPlayerActionDeps) {
+ const { episode, bookmarks, bookmarkTitle, bookmarkNote, duration } = deps.state
+ const t = i18n.global.t
+
+ function isStillLoaded(activeEpisode: PodcastEpisodeSummary, activeGeneration: number): boolean {
+ return activeGeneration === deps.currentGeneration() && episode.value?.id === activeEpisode.id
+ }
+
+ async function markPlayed(): Promise {
+ const activeEpisode = episode.value
+ if (!activeEpisode) return
+ const activeGeneration = deps.currentGeneration()
+ const positionSeconds = duration.value || activeEpisode.durationSeconds || 0
+ await updateEpisodeState(activeEpisode.id, { finished: true, positionSeconds }, 'podcast.errors.markPlayed')
+ if (!isStillLoaded(activeEpisode, activeGeneration)) return
+ episode.value = { ...activeEpisode, finished: true, positionSeconds, progressPercent: 100 }
+ }
+
+ async function restartEpisode(): Promise {
+ const activeEpisode = episode.value
+ if (!activeEpisode) return
+ const activeGeneration = deps.currentGeneration()
+ await updateEpisodeState(activeEpisode.id, { finished: false, positionSeconds: 0 }, 'podcast.errors.restartEpisode')
+ if (!isStillLoaded(activeEpisode, activeGeneration)) return
+ deps.seekTo(0)
+ episode.value = { ...activeEpisode, finished: false, positionSeconds: 0, progressPercent: 0 }
+ }
+
+ async function togglePinned(): Promise {
+ const activeEpisode = episode.value
+ if (!activeEpisode) return
+ const activeGeneration = deps.currentGeneration()
+ const pinned = !activeEpisode.pinned
+ await updateEpisodeState(activeEpisode.id, { pinned }, 'podcast.errors.updatePinned')
+ if (!isStillLoaded(activeEpisode, activeGeneration)) return
+ episode.value = { ...activeEpisode, pinned }
+ }
+
+ async function createBookmark(): Promise {
+ const activeEpisode = episode.value
+ if (!activeEpisode) return
+ const activeGeneration = deps.currentGeneration()
+ const positionSeconds = deps.currentPosition()
+ const title = bookmarkTitle.value.trim() || t('podcast.bookmarks.defaultTitle', { time: formatPlaybackClock(positionSeconds) })
+ const created = await apiJson(
+ `/api/v1/podcast-episodes/${activeEpisode.id}/bookmarks`,
+ jsonBody('POST', { positionSeconds, title, note: bookmarkNote.value.trim() || undefined }),
+ 'podcast.errors.createBookmark',
+ )
+ if (!isStillLoaded(activeEpisode, activeGeneration)) return
+ bookmarks.value.push(created)
+ bookmarks.value.sort((left, right) => left.positionSeconds - right.positionSeconds)
+ bookmarkTitle.value = ''
+ bookmarkNote.value = ''
+ }
+
+ async function updateBookmark(bookmark: PodcastBookmark, title: string, note: string): Promise {
+ const activeEpisode = episode.value
+ const activeGeneration = deps.currentGeneration()
+ const updated = await apiJson(
+ `/api/v1/podcast-bookmarks/${bookmark.id}`,
+ jsonBody('PATCH', { title: title.trim(), note: note.trim() }),
+ 'podcast.errors.updateBookmark',
+ )
+ if (activeEpisode && !isStillLoaded(activeEpisode, activeGeneration)) return
+ bookmarks.value = bookmarks.value.map((item) => (item.id === updated.id ? updated : item))
+ }
+
+ async function deleteBookmark(bookmark: PodcastBookmark): Promise {
+ const activeEpisode = episode.value
+ const activeGeneration = deps.currentGeneration()
+ await apiSend(`/api/v1/podcast-bookmarks/${bookmark.id}`, { method: 'DELETE' }, 'podcast.errors.deleteBookmark')
+ if (activeEpisode && !isStillLoaded(activeEpisode, activeGeneration)) return
+ bookmarks.value = bookmarks.value.filter((item) => item.id !== bookmark.id)
+ }
+
+ async function requestDownload(): Promise {
+ const activeEpisode = episode.value
+ if (!activeEpisode) return
+ const activeGeneration = deps.currentGeneration()
+ await requestEpisodeDownload(activeEpisode.id)
+ if (!isStillLoaded(activeEpisode, activeGeneration)) return
+ episode.value = { ...activeEpisode, mediaStatus: 'queued' }
+ deps.onDownloadRequested()
+ }
+
+ async function removeDownload(): Promise {
+ const activeEpisode = episode.value
+ if (!activeEpisode) return
+ const activeGeneration = deps.currentGeneration()
+ await removeEpisodeDownload(activeEpisode.id)
+ if (!isStillLoaded(activeEpisode, activeGeneration)) return
+ episode.value = { ...activeEpisode, mediaStatus: 'remote', localSizeBytes: null }
+ }
+
+ return { markPlayed, restartEpisode, togglePinned, createBookmark, updateBookmark, deleteBookmark, requestDownload, removeDownload }
+}
diff --git a/client/src/features/podcast/composables/podcast-player-persistence.spec.ts b/client/src/features/podcast/composables/podcast-player-persistence.spec.ts
new file mode 100644
index 000000000..a14e0d8e4
--- /dev/null
+++ b/client/src/features/podcast/composables/podcast-player-persistence.spec.ts
@@ -0,0 +1,131 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastPlaybackPreferences } from '@bookorbit/types'
+import { createPodcastPlayerPersistence } from './podcast-player-persistence'
+
+describe('createPodcastPlayerPersistence', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-08-01T12:00:00.000Z'))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('serializes state writes and stamps each observation before it waits', async () => {
+ let releaseFirst!: () => void
+ const first = new Promise((resolve) => {
+ releaseFirst = () => resolve(new Response(null, { status: 204 }))
+ })
+ const request = vi
+ .fn<(url: string, init?: RequestInit) => Promise>()
+ .mockReturnValueOnce(first)
+ .mockResolvedValue(new Response(null, { status: 204 }))
+ const persistence = createPodcastPlayerPersistence(request)
+
+ const firstWrite = persistence.queueStateWrite(42, { positionSeconds: 10 })
+ await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1))
+ vi.setSystemTime(new Date('2026-08-01T12:00:05.000Z'))
+ const secondWrite = persistence.queueStateWrite(42, { positionSeconds: 20 })
+ expect(request).toHaveBeenCalledTimes(1)
+ releaseFirst()
+ await Promise.all([firstWrite, secondWrite])
+
+ const bodies = request.mock.calls.map(([, init]) => JSON.parse(String(init?.body)) as Record)
+ expect(bodies).toEqual([
+ { positionSeconds: 10, capturedAt: '2026-08-01T12:00:00.000Z' },
+ { positionSeconds: 20, capturedAt: '2026-08-01T12:00:05.000Z' },
+ ])
+ })
+
+ it('drops queued state and preference writes after their generations are invalidated', async () => {
+ const request = vi.fn<(url: string, init?: RequestInit) => Promise>().mockResolvedValue(new Response(null, { status: 204 }))
+ const persistence = createPodcastPlayerPersistence(request)
+
+ const stateWrite = persistence.queueStateWrite(42, { positionSeconds: 10 })
+ persistence.queuePreferenceWrite(preferences())
+ persistence.invalidatePlaybackWrites()
+ persistence.invalidatePreferenceWrites()
+ await stateWrite
+ await persistence.waitForPreferenceWrites()
+
+ expect(request).not.toHaveBeenCalled()
+ })
+
+ it('collapses a burst of preference changes into one write carrying the settled values', async () => {
+ const request = vi.fn<(url: string, init?: RequestInit) => Promise>().mockResolvedValue(new Response(null, { status: 204 }))
+ const persistence = createPodcastPlayerPersistence(request)
+
+ for (const volume of [0.2, 0.4, 0.6, 0.8]) persistence.queuePreferenceWrite(preferences(volume))
+ await vi.advanceTimersByTimeAsync(500)
+
+ expect(request).toHaveBeenCalledOnce()
+ expect(JSON.parse(String(request.mock.calls[0]?.[1]?.body))).toMatchObject({ settings: { volume: 0.8 } })
+ })
+
+ it('flushes a pending preference write rather than waiting out the debounce', async () => {
+ const request = vi.fn<(url: string, init?: RequestInit) => Promise>().mockResolvedValue(new Response(null, { status: 204 }))
+ const persistence = createPodcastPlayerPersistence(request)
+
+ persistence.queuePreferenceWrite(preferences(0.5))
+ await persistence.waitForPreferenceWrites()
+
+ expect(request).toHaveBeenCalledOnce()
+ expect(JSON.parse(String(request.mock.calls[0]?.[1]?.body))).toMatchObject({ settings: { volume: 0.5 } })
+ })
+
+ it('exposes a preference generation guard for stale reads', () => {
+ const persistence = createPodcastPlayerPersistence(vi.fn<(url: string, init?: RequestInit) => Promise>())
+ const generation = persistence.preferenceLoadGeneration()
+ expect(persistence.isPreferenceGenerationCurrent(generation)).toBe(true)
+
+ persistence.invalidatePreferenceWrites()
+
+ expect(persistence.isPreferenceGenerationCurrent(generation)).toBe(false)
+ })
+
+ it('closes a listening session once with its captured timestamps and end position', async () => {
+ const request = vi.fn<(url: string, init?: RequestInit) => Promise>().mockResolvedValue(new Response(null, { status: 204 }))
+ const persistence = createPodcastPlayerPersistence(request)
+ persistence.startListeningSession(new Date('2026-08-01T12:00:00.000Z'), 'session-1')
+ vi.setSystemTime(new Date('2026-08-01T12:02:00.000Z'))
+
+ await persistence.closeListeningSession(42, 75)
+ await persistence.closeListeningSession(42, 100)
+
+ expect(request).toHaveBeenCalledExactlyOnceWith(
+ '/api/v1/podcast-episodes/42/sessions',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({
+ sessionId: 'session-1',
+ startedAt: '2026-08-01T12:00:00.000Z',
+ endedAt: '2026-08-01T12:02:00.000Z',
+ endPositionSeconds: 75,
+ }),
+ }),
+ )
+ })
+
+ it('does not persist sub-second or discarded listening sessions', async () => {
+ const request = vi.fn<(url: string, init?: RequestInit) => Promise>()
+ const persistence = createPodcastPlayerPersistence(request)
+ persistence.startListeningSession(new Date(), 'short')
+ await persistence.closeListeningSession(42, 1)
+ persistence.startListeningSession(new Date(), 'discarded')
+ persistence.discardListeningSession()
+ await persistence.closeListeningSession(42, 2)
+
+ expect(request).not.toHaveBeenCalled()
+ })
+})
+
+function preferences(volume = 1): PodcastPlaybackPreferences {
+ return {
+ defaultPlaybackRate: 1,
+ volume,
+ skipBackwardSeconds: 15,
+ skipForwardSeconds: 30,
+ podcastPlaybackRates: {},
+ }
+}
diff --git a/client/src/features/podcast/composables/podcast-player-persistence.ts b/client/src/features/podcast/composables/podcast-player-persistence.ts
new file mode 100644
index 000000000..c345fdb3a
--- /dev/null
+++ b/client/src/features/podcast/composables/podcast-player-persistence.ts
@@ -0,0 +1,152 @@
+import type { PodcastPlaybackPreferences } from '@bookorbit/types'
+import { api } from '@/lib/api'
+
+type ApiRequest = (url: string, init?: RequestInit) => Promise
+
+/**
+ * A volume drag and a held keyboard step both change the level continuously, and only the value
+ * the user settles on is worth a write. Preferences are small and idempotent, so the whole set is
+ * coalesced rather than just the level.
+ */
+const PREFERENCE_WRITE_DEBOUNCE_MS = 400
+
+export function createPodcastPlayerPersistence(request: ApiRequest = api) {
+ let stateWrite = Promise.resolve()
+ let preferenceWrite = Promise.resolve()
+ let sessionWrite = Promise.resolve()
+ let preferenceGeneration = 0
+ let playbackGeneration = 0
+ let playStartedAt: Date | null = null
+ let sessionId: string | null = null
+ let pendingPreferences: PodcastPlaybackPreferences | null = null
+ let preferenceDebounceTimer: ReturnType | null = null
+
+ function preferenceLoadGeneration(): number {
+ return preferenceGeneration
+ }
+
+ function isPreferenceGenerationCurrent(generation: number): boolean {
+ return generation === preferenceGeneration
+ }
+
+ function invalidatePreferenceWrites() {
+ preferenceGeneration++
+ discardPendingPreferenceWrite()
+ }
+
+ function queuePreferenceWrite(settings: PodcastPlaybackPreferences) {
+ pendingPreferences = settings
+ if (preferenceDebounceTimer) clearTimeout(preferenceDebounceTimer)
+ preferenceDebounceTimer = setTimeout(flushPreferenceWrites, PREFERENCE_WRITE_DEBOUNCE_MS)
+ }
+
+ /** Issues the settled preferences immediately; safe to call when nothing is waiting. */
+ function flushPreferenceWrites() {
+ if (preferenceDebounceTimer) clearTimeout(preferenceDebounceTimer)
+ preferenceDebounceTimer = null
+ const settings = pendingPreferences
+ if (!settings) return
+ pendingPreferences = null
+ const activeGeneration = preferenceGeneration
+ preferenceWrite = preferenceWrite
+ .then(async () => {
+ if (activeGeneration !== preferenceGeneration) return
+ await request('/api/v1/user-preferences/podcast-playback', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ settings }),
+ })
+ })
+ .catch(() => undefined)
+ }
+
+ function discardPendingPreferenceWrite() {
+ if (preferenceDebounceTimer) clearTimeout(preferenceDebounceTimer)
+ preferenceDebounceTimer = null
+ pendingPreferences = null
+ }
+
+ function queueStateWrite(episodeId: number, payload: Record): Promise {
+ const activeGeneration = playbackGeneration
+ const capturedAt = new Date().toISOString()
+ stateWrite = stateWrite
+ .then(async () => {
+ if (activeGeneration !== playbackGeneration) return
+ await request(`/api/v1/podcast-episodes/${episodeId}/state`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...payload, capturedAt }),
+ keepalive: true,
+ })
+ })
+ .catch(() => undefined)
+ return stateWrite
+ }
+
+ function startListeningSession(startedAt = new Date(), activeSessionId: string = crypto.randomUUID()) {
+ playStartedAt = startedAt
+ sessionId = activeSessionId
+ }
+
+ function closeListeningSession(episodeId: number | null, endPositionSeconds: number): Promise {
+ const startedAt = playStartedAt
+ const currentSessionId = sessionId
+ playStartedAt = null
+ sessionId = null
+ if (!episodeId || !startedAt || !currentSessionId) return Promise.resolve()
+ const endedAt = new Date()
+ if (endedAt.getTime() - startedAt.getTime() < 1_000) return Promise.resolve()
+ const activeGeneration = playbackGeneration
+ sessionWrite = sessionWrite
+ .then(async () => {
+ if (activeGeneration !== playbackGeneration) return
+ await request(`/api/v1/podcast-episodes/${episodeId}/sessions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ sessionId: currentSessionId,
+ startedAt: startedAt.toISOString(),
+ endedAt: endedAt.toISOString(),
+ endPositionSeconds,
+ }),
+ keepalive: true,
+ })
+ })
+ .catch(() => undefined)
+ return sessionWrite
+ }
+
+ function discardListeningSession() {
+ playStartedAt = null
+ sessionId = null
+ }
+
+ function invalidatePlaybackWrites() {
+ playbackGeneration++
+ discardListeningSession()
+ }
+
+ async function waitForStateWrites() {
+ await stateWrite.catch(() => undefined)
+ }
+
+ async function waitForPreferenceWrites() {
+ flushPreferenceWrites()
+ await preferenceWrite.catch(() => undefined)
+ }
+
+ return {
+ preferenceLoadGeneration,
+ isPreferenceGenerationCurrent,
+ invalidatePreferenceWrites,
+ queuePreferenceWrite,
+ flushPreferenceWrites,
+ queueStateWrite,
+ startListeningSession,
+ closeListeningSession,
+ discardListeningSession,
+ invalidatePlaybackWrites,
+ waitForStateWrites,
+ waitForPreferenceWrites,
+ }
+}
diff --git a/client/src/features/podcast/composables/podcast-player-preferences.ts b/client/src/features/podcast/composables/podcast-player-preferences.ts
new file mode 100644
index 000000000..222bb147f
--- /dev/null
+++ b/client/src/features/podcast/composables/podcast-player-preferences.ts
@@ -0,0 +1,155 @@
+import type { PodcastEpisodeSummary, PodcastPlaybackPreferences } from '@bookorbit/types'
+import { apiJson } from '@/lib/api-json'
+import { cyclePlaybackRate, isPlaybackRateInRange, stepPlaybackRate } from '../lib/podcast-playback-rates'
+import type { createPodcastPlayerState } from './podcast-player-state'
+import type { createPodcastPlayerPersistence } from './podcast-player-persistence'
+
+type PodcastPlayerState = ReturnType
+type PodcastPlayerPersistence = ReturnType
+
+/** One entry per show, so the map is bounded even for a library with a rate set on everything. */
+const MAX_PER_SHOW_RATES = 1000
+
+export interface PodcastPlayerPreferenceDeps {
+ state: PodcastPlayerState
+ persistence: PodcastPlayerPersistence
+ defaults: PodcastPlaybackPreferences
+ engine: { setRate: (rate: number) => void; setVolume: (volume: number) => void }
+ onPositionStateChanged: () => void
+}
+
+/**
+ * Rate and volume: the two settings that are both live playback controls and stored preferences.
+ * The per-show rate override is an LRU because a user who sets one show to 1.5x expects it to stick,
+ * while a map that grows with the library would eventually be too large to write back.
+ */
+export function createPodcastPlayerPreferences(deps: PodcastPlayerPreferenceDeps) {
+ const { state, persistence, defaults, engine } = deps
+ const { playbackRate, volume, defaultPlaybackRate, skipBackwardSeconds, skipForwardSeconds, podcastPlaybackRates, preferencesLoaded, episode } =
+ state
+ let volumeBeforeMute: number | null = null
+
+ function setPlaybackRate(rate: number, persist = true): void {
+ const next = Number(rate)
+ if (!isPlaybackRateInRange(next)) return
+ playbackRate.value = next
+ engine.setRate(next)
+ deps.onPositionStateChanged()
+ if (!persist) return
+ const activePodcastId = episode.value?.podcastId
+ if (activePodcastId) {
+ const key = String(activePodcastId)
+ const rates = { ...podcastPlaybackRates.value }
+ if (!(key in rates) && Object.keys(rates).length >= MAX_PER_SHOW_RATES) delete rates[Object.keys(rates)[0]!]
+ rates[key] = next
+ podcastPlaybackRates.value = rates
+ } else {
+ defaultPlaybackRate.value = next
+ }
+ persistPreferences()
+ }
+
+ function cycleRate(): void {
+ setPlaybackRate(cyclePlaybackRate(playbackRate.value))
+ }
+
+ function stepRate(direction: -1 | 1): void {
+ const nextRate = stepPlaybackRate(playbackRate.value, direction)
+ if (nextRate !== undefined) setPlaybackRate(nextRate)
+ }
+
+ function clearPlaybackRateOverride(): void {
+ const podcastId = episode.value?.podcastId
+ if (!podcastId) return
+ const rates = { ...podcastPlaybackRates.value }
+ delete rates[String(podcastId)]
+ podcastPlaybackRates.value = rates
+ setPlaybackRate(defaultPlaybackRate.value, false)
+ persistPreferences()
+ }
+
+ function setVolume(value: number, persist = true): void {
+ const next = Math.max(0, Math.min(1, Number(value)))
+ if (!Number.isFinite(next)) return
+ volume.value = next
+ engine.setVolume(next)
+ if (persist) persistPreferences()
+ }
+
+ /** Muting is a session state, so neither silence nor the restored level is written back as the stored preference. */
+ function toggleMute(): void {
+ if (volume.value > 0) {
+ volumeBeforeMute = volume.value
+ setVolume(0, false)
+ return
+ }
+ const restored = volumeBeforeMute && volumeBeforeMute > 0 ? volumeBeforeMute : defaults.volume
+ volumeBeforeMute = null
+ setVolume(restored, false)
+ }
+
+ function applyEpisodePlaybackRate(activeEpisode: PodcastEpisodeSummary): void {
+ setPlaybackRate(podcastPlaybackRates.value[String(activeEpisode.podcastId)] ?? defaultPlaybackRate.value, false)
+ }
+
+ async function loadPreferences(): Promise {
+ if (preferencesLoaded.value) return
+ const activeGeneration = persistence.preferenceLoadGeneration()
+ const body = await apiJson<{ settings: PodcastPlaybackPreferences }>(
+ '/api/v1/user-preferences/podcast-playback',
+ undefined,
+ 'podcast.errors.loadPlaybackSettings',
+ ).catch(() => null)
+ if (!body || !persistence.isPreferenceGenerationCurrent(activeGeneration)) return
+ const settings = body.settings
+ setVolume(settings.volume ?? defaults.volume, false)
+ if (Number.isFinite(settings.defaultPlaybackRate)) {
+ defaultPlaybackRate.value = settings.defaultPlaybackRate
+ setPlaybackRate(settings.defaultPlaybackRate, false)
+ }
+ if (Number.isInteger(settings.skipBackwardSeconds)) skipBackwardSeconds.value = settings.skipBackwardSeconds
+ if (Number.isInteger(settings.skipForwardSeconds)) skipForwardSeconds.value = settings.skipForwardSeconds
+ podcastPlaybackRates.value = settings.podcastPlaybackRates ?? {}
+ if (episode.value) applyEpisodePlaybackRate(episode.value)
+ preferencesLoaded.value = true
+ }
+
+ function resetPreferences(): void {
+ persistence.invalidatePreferenceWrites()
+ preferencesLoaded.value = false
+ volumeBeforeMute = null
+ defaultPlaybackRate.value = defaults.defaultPlaybackRate
+ volume.value = defaults.volume
+ playbackRate.value = defaults.defaultPlaybackRate
+ skipBackwardSeconds.value = defaults.skipBackwardSeconds
+ skipForwardSeconds.value = defaults.skipForwardSeconds
+ podcastPlaybackRates.value = {}
+ engine.setRate(defaults.defaultPlaybackRate)
+ engine.setVolume(defaults.volume)
+ }
+
+ function persistPreferences(): void {
+ persistence.queuePreferenceWrite({
+ defaultPlaybackRate: defaultPlaybackRate.value,
+ // While muted the live level is 0, which is session state. Writing it would let any unrelated
+ // preference change - a speed pick, a skip-length edit - overwrite the stored volume with
+ // silence, so the player comes back mute on the next load with the chosen level gone.
+ volume: volumeBeforeMute ?? volume.value,
+ skipBackwardSeconds: skipBackwardSeconds.value,
+ skipForwardSeconds: skipForwardSeconds.value,
+ podcastPlaybackRates: { ...podcastPlaybackRates.value },
+ })
+ }
+
+ return {
+ setPlaybackRate,
+ cycleRate,
+ stepRate,
+ clearPlaybackRateOverride,
+ setVolume,
+ toggleMute,
+ applyEpisodePlaybackRate,
+ loadPreferences,
+ resetPreferences,
+ }
+}
diff --git a/client/src/features/podcast/composables/podcast-player-queue.ts b/client/src/features/podcast/composables/podcast-player-queue.ts
new file mode 100644
index 000000000..00d20aefd
--- /dev/null
+++ b/client/src/features/podcast/composables/podcast-player-queue.ts
@@ -0,0 +1,106 @@
+import type { PodcastQueuePlacement } from '@bookorbit/types'
+import type { createPodcastPlayerState } from './podcast-player-state'
+import type { createPodcastPlayerPersistence } from './podcast-player-persistence'
+import { notifyEpisodesDequeued, type usePodcastQueue } from './usePodcastQueue'
+
+type PodcastPlayerState = ReturnType
+type PodcastPlayerPersistence = ReturnType
+type PodcastQueue = ReturnType
+
+export interface PodcastPlayerQueueDeps {
+ state: PodcastPlayerState
+ queue: PodcastQueue
+ persistence: PodcastPlayerPersistence
+ refreshPlaybackContext: () => Promise
+ loadEpisode: (episodeId: number, autoplay?: boolean) => Promise
+}
+
+/**
+ * The player's half of the queue: mutations it issues for the loaded episode, and the context
+ * re-read that follows any queue change from anywhere in the app.
+ */
+export function createPodcastPlayerQueue(deps: PodcastPlayerQueueDeps) {
+ const { episode, previousQueueItem, nextQueueItem } = deps.state
+ let refreshPromise: Promise | null = null
+ let refreshRequested = false
+
+ /**
+ * Coalesces bursts of queue changes into one context read, and keeps reading while more arrive so
+ * the last change is always reflected rather than dropped alongside an in-flight request.
+ */
+ function scheduleQueueContextRefresh(): Promise {
+ refreshRequested = true
+ if (refreshPromise) return refreshPromise
+ refreshPromise = (async () => {
+ while (refreshRequested) {
+ refreshRequested = false
+ if (episode.value) await deps.refreshPlaybackContext()
+ }
+ })().finally(() => {
+ refreshPromise = null
+ })
+ return refreshPromise
+ }
+
+ async function waitForQueueContextRefresh(): Promise {
+ await refreshPromise?.catch(() => undefined)
+ }
+
+ async function addToQueue(placement: PodcastQueuePlacement = 'end'): Promise {
+ const activeEpisode = episode.value
+ if (!activeEpisode) return
+ await deps.queue.add(activeEpisode.id, placement)
+ await waitForQueueContextRefresh()
+ }
+
+ async function queueEpisodeNext(episodeId: number): Promise {
+ const activeEpisode = episode.value
+ await deps.queue.addNext(episodeId, activeEpisode ? { episodeId: activeEpisode.id, queued: activeEpisode.queued } : undefined)
+ await waitForQueueContextRefresh()
+ }
+
+ async function removeFromQueue(episodeId = episode.value?.id): Promise {
+ if (!episodeId) return
+ await deps.queue.remove(episodeId)
+ await waitForQueueContextRefresh()
+ }
+
+ async function playPrevious(): Promise {
+ const previousEpisode = previousQueueItem.value
+ if (!previousEpisode) return
+ await deps.loadEpisode(previousEpisode.id, true)
+ }
+
+ async function playNext(): Promise {
+ const nextEpisode = nextQueueItem.value
+ if (!nextEpisode) return
+ await deps.loadEpisode(nextEpisode.id, true)
+ }
+
+ async function advanceAfterPlaybackEnd(): Promise {
+ const nextEpisode = nextQueueItem.value
+ const finishedEpisodeId = episode.value?.id
+ // The finished-state PATCH dequeues the completed episode server-side, so the next context
+ // read has to wait for that write to land or it would still count the finished entry.
+ await deps.persistence.waitForStateWrites()
+ // Advancing into an episode whose media is gone strands the player on a load error that no
+ // retry can clear, and the mini player carries it to every route. Nobody asked for this
+ // episode, so stopping on the finished one is the better end state than a dead player.
+ if (nextEpisode && nextEpisode.mediaStatus !== 'unavailable') await deps.loadEpisode(nextEpisode.id, true)
+ else if (episode.value) await deps.refreshPlaybackContext().catch(() => undefined)
+ // Announced after the player has re-read its own context, so a rendered queue elsewhere drops
+ // the finished row too. This write goes straight to the state endpoint, so nothing else sees it.
+ if (finishedEpisodeId !== undefined) notifyEpisodesDequeued([finishedEpisodeId])
+ }
+
+ return {
+ scheduleQueueContextRefresh,
+ waitForQueueContextRefresh,
+ addToQueue,
+ queueEpisodeNext,
+ removeFromQueue,
+ playPrevious,
+ playNext,
+ advanceAfterPlaybackEnd,
+ }
+}
diff --git a/client/src/features/podcast/composables/podcast-player-state.spec.ts b/client/src/features/podcast/composables/podcast-player-state.spec.ts
new file mode 100644
index 000000000..7656a1374
--- /dev/null
+++ b/client/src/features/podcast/composables/podcast-player-state.spec.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from 'vitest'
+import type { PodcastEpisodeSummary, PodcastPlaybackPreferences } from '@bookorbit/types'
+import { createPodcastPlayerState } from './podcast-player-state'
+
+describe('createPodcastPlayerState', () => {
+ it('derives progress, chapter, and per-podcast rate state from one reactive graph', () => {
+ const state = createPodcastPlayerState(preferences())
+ state.episode.value = episode()
+ state.duration.value = 300
+ state.currentTime.value = 125
+
+ expect(state.progressPercent.value).toBeCloseTo(41.67, 2)
+ expect(state.activeChapterIndex.value).toBe(1)
+ expect(state.hasPlaybackRateOverride.value).toBe(false)
+
+ state.podcastPlaybackRates.value = { '3': 1.5 }
+ expect(state.hasPlaybackRateOverride.value).toBe(true)
+ })
+
+ it('copies mutable preference defaults between independently created state graphs', () => {
+ const defaults = preferences()
+ const first = createPodcastPlayerState(defaults)
+ const second = createPodcastPlayerState(defaults)
+ first.podcastPlaybackRates.value['3'] = 2
+
+ expect(second.podcastPlaybackRates.value).toEqual({})
+ expect(defaults.podcastPlaybackRates).toEqual({})
+ })
+})
+
+function preferences(): PodcastPlaybackPreferences {
+ return {
+ defaultPlaybackRate: 1,
+ volume: 1,
+ skipBackwardSeconds: 15,
+ skipForwardSeconds: 30,
+ podcastPlaybackRates: {},
+ }
+}
+
+function episode(): PodcastEpisodeSummary {
+ return {
+ id: 42,
+ libraryId: 2,
+ origin: 'feed',
+ podcastId: 3,
+ podcastTitle: 'Orbit Radio',
+ podcastImageUrl: null,
+ title: 'First episode',
+ subtitle: null,
+ description: null,
+ publishedAt: null,
+ season: null,
+ episode: null,
+ episodeType: null,
+ durationSeconds: 300,
+ audioFormat: 'mp3',
+ explicit: false,
+ chapters: [
+ { title: 'One', startSeconds: 0 },
+ { title: 'Two', startSeconds: 100 },
+ ],
+ transcripts: [],
+ lockedFields: [],
+ inFeed: true,
+ mediaStatus: 'remote',
+ localSizeBytes: null,
+ checksum: null,
+ positionSeconds: 30,
+ progressPercent: 10,
+ finished: false,
+ pinned: false,
+ queued: false,
+ lastListenedAt: null,
+ createdAt: '2026-07-10T00:00:00.000Z',
+ updatedAt: '2026-07-10T00:00:00.000Z',
+ }
+}
diff --git a/client/src/features/podcast/composables/podcast-player-state.ts b/client/src/features/podcast/composables/podcast-player-state.ts
new file mode 100644
index 000000000..1895731ec
--- /dev/null
+++ b/client/src/features/podcast/composables/podcast-player-state.ts
@@ -0,0 +1,72 @@
+import { computed, ref } from 'vue'
+import type { PodcastBookmark, PodcastEpisodeSummary, PodcastPlaybackContext, PodcastPlaybackPreferences } from '@bookorbit/types'
+
+export function createPodcastPlayerState(defaultPreferences: PodcastPlaybackPreferences) {
+ const episode = ref(null)
+ const playbackContext = ref(null)
+ const bookmarks = ref([])
+ const loading = ref(false)
+ const error = ref(null)
+ const isPlaying = ref(false)
+ const isAutoplayPending = ref(false)
+ const currentTime = ref(0)
+ const duration = ref(0)
+ const playbackRate = ref(defaultPreferences.defaultPlaybackRate)
+ const volume = ref(defaultPreferences.volume)
+ const defaultPlaybackRate = ref(defaultPreferences.defaultPlaybackRate)
+ const skipBackwardSeconds = ref(defaultPreferences.skipBackwardSeconds)
+ const skipForwardSeconds = ref(defaultPreferences.skipForwardSeconds)
+ const podcastPlaybackRates = ref>({ ...defaultPreferences.podcastPlaybackRates })
+ const bookmarkTitle = ref('')
+ const bookmarkNote = ref('')
+ const preferencesLoaded = ref(false)
+ const downloadProgress = ref<{ receivedBytes: number; totalBytes: number | null } | null>(null)
+
+ const progressPercent = computed(() => (duration.value > 0 ? Math.min(100, (currentTime.value / duration.value) * 100) : 0))
+ const previousQueueItem = computed(() => playbackContext.value?.navigation.previous ?? null)
+ const nextQueueItem = computed(() => playbackContext.value?.navigation.next ?? null)
+ const upcomingQueueItems = computed(() => playbackContext.value?.queue.upcoming ?? [])
+ const queuePosition = computed(() => playbackContext.value?.queue.position ?? null)
+ const queueTotal = computed(() => playbackContext.value?.queue.total ?? 0)
+ const navigationSource = computed(() => playbackContext.value?.navigation.source ?? null)
+ const activeChapterIndex = computed(() => {
+ const chapters = episode.value?.chapters ?? []
+ for (let index = chapters.length - 1; index >= 0; index--) if (currentTime.value >= chapters[index]!.startSeconds) return index
+ return -1
+ })
+ const hasPlaybackRateOverride = computed(() => {
+ const podcastId = episode.value?.podcastId
+ return podcastId ? String(podcastId) in podcastPlaybackRates.value : false
+ })
+
+ return {
+ episode,
+ playbackContext,
+ bookmarks,
+ loading,
+ error,
+ isPlaying,
+ isAutoplayPending,
+ currentTime,
+ duration,
+ playbackRate,
+ volume,
+ defaultPlaybackRate,
+ skipBackwardSeconds,
+ skipForwardSeconds,
+ podcastPlaybackRates,
+ bookmarkTitle,
+ bookmarkNote,
+ preferencesLoaded,
+ downloadProgress,
+ progressPercent,
+ previousQueueItem,
+ nextQueueItem,
+ upcomingQueueItems,
+ queuePosition,
+ queueTotal,
+ navigationSource,
+ activeChapterIndex,
+ hasPlaybackRateOverride,
+ }
+}
diff --git a/client/src/features/podcast/composables/podcast-sleep-timer.spec.ts b/client/src/features/podcast/composables/podcast-sleep-timer.spec.ts
new file mode 100644
index 000000000..f78099aa0
--- /dev/null
+++ b/client/src/features/podcast/composables/podcast-sleep-timer.spec.ts
@@ -0,0 +1,88 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createPodcastSleepTimer } from './podcast-sleep-timer'
+
+describe('createPodcastSleepTimer', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-08-01T12:00:00.000Z'))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('expires a countdown once and disposes its ticker', async () => {
+ const onExpire = vi.fn<(pausePlayback: boolean) => void>()
+ const timer = createPodcastSleepTimer({ onExpire, onExtend: vi.fn<(minutes: number) => void>() })
+
+ timer.setTimer(5)
+ expect(timer.sleepRemainingSeconds.value).toBe(300)
+
+ await vi.advanceTimersByTimeAsync(5 * 60_000)
+
+ expect(onExpire).toHaveBeenCalledWith(true)
+ expect(timer.sleepRemainingSeconds.value).toBeNull()
+ expect(timer.sleepTimerMinutes.value).toBeNull()
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it('extends only a running countdown from its existing deadline', async () => {
+ const onExtend = vi.fn<(minutes: number) => void>()
+ const timer = createPodcastSleepTimer({ onExpire: vi.fn<(pausePlayback: boolean) => void>(), onExtend })
+ timer.extendTimer(5)
+ expect(onExtend).not.toHaveBeenCalled()
+
+ timer.setTimer(5)
+ await vi.advanceTimersByTimeAsync(60_000)
+ timer.extendTimer(5)
+
+ expect(timer.sleepRemainingSeconds.value).toBe(9 * 60)
+ expect(timer.sleepTimerMinutes.value).toBe(10)
+ expect(onExtend).toHaveBeenCalledWith(5)
+ })
+
+ it('captures the selected chapter boundary across later positions', () => {
+ const onExpire = vi.fn<(pausePlayback: boolean) => void>()
+ const timer = createPodcastSleepTimer({ onExpire, onExtend: vi.fn<(minutes: number) => void>() })
+ timer.setAtEnd('chapter', {
+ chapters: [
+ { title: 'One', startSeconds: 0 },
+ { title: 'Two', startSeconds: 100 },
+ { title: 'Three', startSeconds: 200 },
+ ],
+ activeChapterIndex: 0,
+ duration: 300,
+ })
+
+ timer.handlePosition(99)
+ expect(onExpire).not.toHaveBeenCalled()
+ timer.handlePosition(100)
+
+ expect(onExpire).toHaveBeenCalledOnce()
+ expect(timer.sleepEndMode.value).toBeNull()
+ })
+
+ it('uses the episode boundary for the final chapter and ignores chapter mode without chapters', () => {
+ const onExpire = vi.fn<(pausePlayback: boolean) => void>()
+ const timer = createPodcastSleepTimer({ onExpire, onExtend: vi.fn<(minutes: number) => void>() })
+ timer.setAtEnd('chapter', { chapters: [], activeChapterIndex: -1, duration: 300 })
+ expect(timer.sleepEndMode.value).toBeNull()
+
+ timer.setAtEnd('chapter', { chapters: [{ title: 'Only', startSeconds: 0 }], activeChapterIndex: 0, duration: 300 })
+ timer.handlePosition(299)
+ expect(onExpire).not.toHaveBeenCalled()
+ timer.handlePosition(300)
+ expect(onExpire).toHaveBeenCalledOnce()
+ })
+
+ it('reports an episode-end interception only while an end mode is active', () => {
+ const onExpire = vi.fn<(pausePlayback: boolean) => void>()
+ const timer = createPodcastSleepTimer({ onExpire, onExtend: vi.fn<(minutes: number) => void>() })
+ expect(timer.handleEpisodeEnd()).toBe(false)
+
+ timer.setAtEnd('episode', { chapters: [], activeChapterIndex: -1, duration: 300 })
+ expect(timer.handleEpisodeEnd()).toBe(true)
+ expect(timer.handleEpisodeEnd()).toBe(false)
+ expect(onExpire).toHaveBeenCalledExactlyOnceWith(false)
+ })
+})
diff --git a/client/src/features/podcast/composables/podcast-sleep-timer.ts b/client/src/features/podcast/composables/podcast-sleep-timer.ts
new file mode 100644
index 000000000..87289747d
--- /dev/null
+++ b/client/src/features/podcast/composables/podcast-sleep-timer.ts
@@ -0,0 +1,114 @@
+import { computed, ref } from 'vue'
+import type { PodcastChapter } from '@bookorbit/types'
+
+const TICK_INTERVAL_MS = 1_000
+const DEFAULT_EXTEND_MINUTES = 5
+
+interface PodcastSleepTimerOptions {
+ onExpire: (pausePlayback: boolean) => void
+ onExtend: (minutes: number) => void
+}
+
+interface ChapterSleepContext {
+ chapters: PodcastChapter[]
+ activeChapterIndex: number
+ duration: number
+}
+
+export function createPodcastSleepTimer(options: PodcastSleepTimerOptions) {
+ const sleepEndsAt = ref(null)
+ const sleepEndMode = ref<'chapter' | 'episode' | null>(null)
+ const sleepChapterEndAt = ref(null)
+ const sleepTimerMinutes = ref(null)
+ const sleepNow = ref(Date.now())
+ const sleepRemainingSeconds = computed(() =>
+ sleepEndsAt.value ? Math.max(0, Math.ceil((sleepEndsAt.value - sleepNow.value) / TICK_INTERVAL_MS)) : null,
+ )
+ let ticker: ReturnType | null = null
+
+ function setTimer(minutes: number) {
+ if (!Number.isFinite(minutes) || minutes <= 0) return
+ sleepEndMode.value = null
+ sleepChapterEndAt.value = null
+ sleepTimerMinutes.value = minutes
+ const now = Date.now()
+ sleepNow.value = now
+ sleepEndsAt.value = now + minutes * 60_000
+ startTicker()
+ }
+
+ function extendTimer(minutes = DEFAULT_EXTEND_MINUTES) {
+ if (!Number.isFinite(minutes) || minutes <= 0 || sleepEndsAt.value === null) return
+ const now = Date.now()
+ sleepNow.value = now
+ sleepEndsAt.value = Math.max(sleepEndsAt.value, now) + minutes * 60_000
+ sleepTimerMinutes.value = (sleepTimerMinutes.value ?? 0) + minutes
+ startTicker()
+ options.onExtend(minutes)
+ }
+
+ function setAtEnd(mode: 'chapter' | 'episode', context: ChapterSleepContext) {
+ if (mode === 'chapter' && context.chapters.length === 0) return
+ sleepEndsAt.value = null
+ sleepEndMode.value = mode
+ if (mode === 'chapter') {
+ const currentChapter = Math.max(0, context.activeChapterIndex)
+ sleepChapterEndAt.value = context.chapters[currentChapter + 1]?.startSeconds ?? context.duration
+ } else {
+ sleepChapterEndAt.value = null
+ }
+ sleepTimerMinutes.value = null
+ stopTicker()
+ }
+
+ function handlePosition(position: number) {
+ const chapterEndAt = sleepChapterEndAt.value
+ if (sleepEndMode.value === 'chapter' && chapterEndAt !== null && chapterEndAt > 0 && position >= chapterEndAt) expire()
+ }
+
+ function handleEpisodeEnd(): boolean {
+ if (sleepEndMode.value !== 'episode' && sleepEndMode.value !== 'chapter') return false
+ clear()
+ options.onExpire(false)
+ return true
+ }
+
+ function expire() {
+ clear()
+ options.onExpire(true)
+ }
+
+ function clear() {
+ sleepEndsAt.value = null
+ sleepEndMode.value = null
+ sleepChapterEndAt.value = null
+ sleepTimerMinutes.value = null
+ stopTicker()
+ }
+
+ function startTicker() {
+ stopTicker()
+ ticker = setInterval(() => {
+ const now = Date.now()
+ sleepNow.value = now
+ if (sleepEndsAt.value !== null && now >= sleepEndsAt.value) expire()
+ }, TICK_INTERVAL_MS)
+ }
+
+ function stopTicker() {
+ if (ticker) clearInterval(ticker)
+ ticker = null
+ }
+
+ return {
+ sleepRemainingSeconds,
+ sleepEndMode,
+ sleepTimerMinutes,
+ setTimer,
+ extendTimer,
+ setAtEnd,
+ handlePosition,
+ handleEpisodeEnd,
+ clear,
+ }
+}
diff --git a/client/src/features/podcast/composables/useEpisodeQuickView.ts b/client/src/features/podcast/composables/useEpisodeQuickView.ts
new file mode 100644
index 000000000..31a21cc8a
--- /dev/null
+++ b/client/src/features/podcast/composables/useEpisodeQuickView.ts
@@ -0,0 +1,33 @@
+import { ref } from 'vue'
+import type { PodcastEpisodeListItem } from '@bookorbit/types'
+
+/**
+ * The quick-view sheet and the metadata editor are opened from a row in three different views, and
+ * each was wiring the same pair of "which episode" plus "is it open" refs by hand.
+ */
+export function useEpisodeQuickView() {
+ const quickViewEpisode = ref(null)
+ const quickViewOpen = ref(false)
+ const editEpisodeId = ref(null)
+ const editEpisodeOpen = ref(false)
+
+ function openDetails(episode: PodcastEpisodeListItem): void {
+ quickViewEpisode.value = episode
+ quickViewOpen.value = true
+ }
+
+ function setQuickViewOpen(open: boolean): void {
+ quickViewOpen.value = open
+ }
+
+ function openMetadataEditor(episode: PodcastEpisodeListItem): void {
+ editEpisodeId.value = episode.id
+ editEpisodeOpen.value = true
+ }
+
+ function setEditOpen(open: boolean): void {
+ editEpisodeOpen.value = open
+ }
+
+ return { quickViewEpisode, quickViewOpen, editEpisodeId, editEpisodeOpen, openDetails, setQuickViewOpen, openMetadataEditor, setEditOpen }
+}
diff --git a/client/src/features/podcast/composables/usePodcastAnnouncer.spec.ts b/client/src/features/podcast/composables/usePodcastAnnouncer.spec.ts
new file mode 100644
index 000000000..cae449776
--- /dev/null
+++ b/client/src/features/podcast/composables/usePodcastAnnouncer.spec.ts
@@ -0,0 +1,49 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { usePodcastAnnouncer } from './usePodcastAnnouncer'
+
+describe('usePodcastAnnouncer', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('exposes the latest announcement', async () => {
+ const { message, announce } = usePodcastAnnouncer()
+
+ await announce('Launch added to the queue')
+
+ expect(message.value).toBe('Launch added to the queue')
+ })
+
+ it('clears the region first so an identical repeat is still announced', async () => {
+ const { message, announce } = usePodcastAnnouncer()
+ await announce('Launch added to the queue')
+
+ const pending = announce('Launch added to the queue')
+ expect(message.value).toBe('')
+
+ await pending
+ expect(message.value).toBe('Launch added to the queue')
+ })
+
+ it('drops the announcement so it is not read again on the next focus pass', async () => {
+ const { message, announce } = usePodcastAnnouncer()
+
+ await announce('Launch finished downloading')
+ await vi.advanceTimersByTimeAsync(8_000)
+
+ expect(message.value).toBe('')
+ })
+
+ it('ignores blank announcements', async () => {
+ const { message, announce } = usePodcastAnnouncer()
+ await announce('Launch added to the queue')
+
+ await announce(' ')
+
+ expect(message.value).toBe('Launch added to the queue')
+ })
+})
diff --git a/client/src/features/podcast/composables/usePodcastAnnouncer.ts b/client/src/features/podcast/composables/usePodcastAnnouncer.ts
new file mode 100644
index 000000000..a561b3ff3
--- /dev/null
+++ b/client/src/features/podcast/composables/usePodcastAnnouncer.ts
@@ -0,0 +1,30 @@
+import { nextTick, ref } from 'vue'
+
+const ANNOUNCEMENT_TTL_MS = 8_000
+
+const message = ref('')
+let resetTimer: ReturnType | null = null
+
+/**
+ * Single polite live region for podcast state changes that produce no toast: download transitions,
+ * queue additions and removals, and sleep-timer expiry. Toasts carry their own live region, so
+ * anything already toasted must not be announced here as well.
+ */
+export function usePodcastAnnouncer() {
+ async function announce(text: string): Promise {
+ const next = text.trim()
+ if (!next) return
+ if (message.value === next) {
+ message.value = ''
+ await nextTick()
+ }
+ message.value = next
+ if (resetTimer) clearTimeout(resetTimer)
+ resetTimer = setTimeout(() => {
+ message.value = ''
+ resetTimer = null
+ }, ANNOUNCEMENT_TTL_MS)
+ }
+
+ return { message, announce }
+}
diff --git a/client/src/features/podcast/composables/usePodcastArtworkEditor.spec.ts b/client/src/features/podcast/composables/usePodcastArtworkEditor.spec.ts
new file mode 100644
index 000000000..2ff4b90ea
--- /dev/null
+++ b/client/src/features/podcast/composables/usePodcastArtworkEditor.spec.ts
@@ -0,0 +1,104 @@
+// @vitest-environment node
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { effectScope, ref } from 'vue'
+import { PODCAST_ERROR_CODES } from '@bookorbit/types'
+import { api } from '@/lib/api'
+import { jsonResponse } from '../test/fixtures'
+import { usePodcastArtworkEditor } from './usePodcastArtworkEditor'
+
+vi.mock('@/lib/api', () => ({ api: vi.fn<(url: string, init?: RequestInit) => Promise>() }))
+
+const apiMock = vi.mocked(api)
+const objectUrls = { created: [] as File[], revoked: [] as string[] }
+
+const artwork = { id: 12, imageUrl: '/api/v1/podcasts/12/artwork?v=2', artworkUpdatedAt: '2026-08-01T00:00:00.000Z' }
+
+beforeEach(() => {
+ apiMock.mockReset()
+ objectUrls.created = []
+ objectUrls.revoked = []
+ vi.stubGlobal('URL', {
+ ...URL,
+ createObjectURL: (file: File) => {
+ objectUrls.created.push(file)
+ return `blob:podcast-${objectUrls.created.length}`
+ },
+ revokeObjectURL: (value: string) => objectUrls.revoked.push(value),
+ })
+})
+
+describe('usePodcastArtworkEditor', () => {
+ it('previews a chosen file without writing anything until it is confirmed', async () => {
+ apiMock.mockResolvedValue(jsonResponse(artwork))
+ const editor = usePodcastArtworkEditor(ref(12))
+ const file = new File(['bytes'], 'cover.png', { type: 'image/png' })
+
+ editor.selectFile(file)
+
+ expect(editor.previewSrc.value).toBe('blob:podcast-1')
+ expect(apiMock).not.toHaveBeenCalled()
+
+ await expect(editor.confirm()).resolves.toEqual(artwork)
+
+ const [url, init] = apiMock.mock.calls[0]!
+ expect(url).toBe('/api/v1/podcasts/12/artwork')
+ expect(init?.method).toBe('POST')
+ expect(init?.body).toBeInstanceOf(FormData)
+ // Confirming clears the pending state, so a second confirm has nothing left to send.
+ expect(editor.pendingFile.value).toBeNull()
+ expect(objectUrls.revoked).toContain('blob:podcast-1')
+ })
+
+ it('sends a pasted address to the from-url endpoint instead of uploading bytes', async () => {
+ apiMock.mockResolvedValue(jsonResponse(artwork))
+ const editor = usePodcastArtworkEditor(ref(12))
+
+ editor.setUrl(' https://cdn.example/art.png ')
+ await editor.confirm()
+
+ expect(apiMock).toHaveBeenCalledWith(
+ '/api/v1/podcasts/12/artwork/from-url',
+ expect.objectContaining({ method: 'POST', body: JSON.stringify({ url: 'https://cdn.example/art.png' }) }),
+ )
+ })
+
+ it('does nothing when neither a file nor an address is pending', async () => {
+ const editor = usePodcastArtworkEditor(ref(12))
+
+ await expect(editor.confirm()).resolves.toBeNull()
+ expect(apiMock).not.toHaveBeenCalled()
+ })
+
+ it('surfaces a localized failure rather than the server message', async () => {
+ apiMock.mockResolvedValue(jsonResponse({ errorCode: PODCAST_ERROR_CODES.storageFull, message: 'Payload Too Large' }, 413))
+ const editor = usePodcastArtworkEditor(ref(12))
+ editor.setUrl('https://cdn.example/art.png')
+
+ await expect(editor.confirm()).resolves.toBeNull()
+
+ expect(editor.error.value).not.toBe('Payload Too Large')
+ expect(editor.error.value).toBeTruthy()
+ // A failed confirm keeps the pending value so the user can retry it.
+ expect(editor.pendingUrl.value).toBe('https://cdn.example/art.png')
+ })
+
+ it('reverts to the feed artwork through the delete endpoint', async () => {
+ apiMock.mockResolvedValue(jsonResponse({ ...artwork, artworkUpdatedAt: null }))
+ const editor = usePodcastArtworkEditor(ref(12))
+
+ await expect(editor.revert()).resolves.toMatchObject({ artworkUpdatedAt: null })
+ expect(apiMock).toHaveBeenCalledWith('/api/v1/podcasts/12/artwork', { method: 'DELETE' })
+ })
+
+ it('releases the object URL when its scope goes away', () => {
+ const scope = effectScope()
+ scope.run(() => {
+ const editor = usePodcastArtworkEditor(ref(12))
+ editor.selectFile(new File(['bytes'], 'cover.png', { type: 'image/png' }))
+ })
+
+ scope.stop()
+
+ expect(objectUrls.revoked).toContain('blob:podcast-1')
+ })
+})
diff --git a/client/src/features/podcast/composables/usePodcastArtworkEditor.ts b/client/src/features/podcast/composables/usePodcastArtworkEditor.ts
new file mode 100644
index 000000000..cf6ab898c
--- /dev/null
+++ b/client/src/features/podcast/composables/usePodcastArtworkEditor.ts
@@ -0,0 +1,91 @@
+import { onScopeDispose, ref, type Ref } from 'vue'
+import type { PodcastArtworkResult } from '@bookorbit/types'
+import { apiJson, jsonBody } from '@/lib/api-json'
+
+/**
+ * Pending-then-confirm artwork editing, mirroring the book cover editor: a chosen file or URL is
+ * only previewed until it is confirmed, so leaving the sheet never rewrites the stored artwork.
+ */
+export function usePodcastArtworkEditor(podcastId: Ref) {
+ const busy = ref(false)
+ const error = ref(null)
+ const previewSrc = ref(null)
+ const pendingFile = ref(null)
+ const pendingUrl = ref(null)
+
+ function revokePreview() {
+ if (previewSrc.value?.startsWith('blob:')) URL.revokeObjectURL(previewSrc.value)
+ }
+
+ function selectFile(file: File) {
+ clearPending()
+ pendingFile.value = file
+ previewSrc.value = URL.createObjectURL(file)
+ }
+
+ function setUrl(url: string) {
+ revokePreview()
+ const normalized = url.trim()
+ pendingFile.value = null
+ pendingUrl.value = normalized || null
+ previewSrc.value = null
+ error.value = null
+ }
+
+ function clearPending() {
+ revokePreview()
+ previewSrc.value = null
+ pendingFile.value = null
+ pendingUrl.value = null
+ error.value = null
+ }
+
+ async function confirm(): Promise {
+ if (!pendingFile.value && !pendingUrl.value) return null
+ busy.value = true
+ error.value = null
+ try {
+ const result = pendingFile.value ? await uploadFile(pendingFile.value) : await uploadUrl(pendingUrl.value!)
+ clearPending()
+ return result
+ } catch (reason) {
+ error.value = reason instanceof Error ? reason.message : null
+ return null
+ } finally {
+ busy.value = false
+ }
+ }
+
+ async function revert(): Promise {
+ busy.value = true
+ error.value = null
+ try {
+ const result = await apiJson(
+ `/api/v1/podcasts/${podcastId.value}/artwork`,
+ { method: 'DELETE' },
+ 'podcast.errors.removeArtwork',
+ )
+ clearPending()
+ return result
+ } catch (reason) {
+ error.value = reason instanceof Error ? reason.message : null
+ return null
+ } finally {
+ busy.value = false
+ }
+ }
+
+ function uploadFile(file: File): Promise {
+ const body = new FormData()
+ body.append('file', file)
+ return apiJson(`/api/v1/podcasts/${podcastId.value}/artwork`, { method: 'POST', body }, 'podcast.errors.updateArtwork')
+ }
+
+ function uploadUrl(url: string): Promise {
+ return apiJson(`/api/v1/podcasts/${podcastId.value}/artwork/from-url`, jsonBody('POST', { url }), 'podcast.errors.updateArtwork')
+ }
+
+ onScopeDispose(revokePreview)
+
+ return { busy, error, previewSrc, pendingFile, pendingUrl, selectFile, setUrl, clearPending, confirm, revert }
+}
diff --git a/client/src/features/podcast/composables/usePodcastBulkDelete.spec.ts b/client/src/features/podcast/composables/usePodcastBulkDelete.spec.ts
new file mode 100644
index 000000000..3a4687316
--- /dev/null
+++ b/client/src/features/podcast/composables/usePodcastBulkDelete.spec.ts
@@ -0,0 +1,116 @@
+// @vitest-environment node
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+import { api } from '@/lib/api'
+import { usePodcastBulkDelete } from './usePodcastBulkDelete'
+
+vi.mock('@/lib/api', () => ({
+ api: vi.fn<(url: string, init?: RequestInit) => Promise>(),
+ NetworkError: class NetworkError extends Error {},
+}))
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
+
+const apiMock = vi.mocked(api)
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
+}
+
+function lastRequest() {
+ const call = apiMock.mock.calls.at(-1)
+ if (!call) throw new Error('expected a request')
+ return { url: String(call[0]), body: JSON.parse(String(call[1]?.body ?? '{}')) as { podcastIds: number[] } }
+}
+
+beforeEach(() => {
+ apiMock.mockReset()
+})
+
+describe('usePodcastBulkDelete', () => {
+ it('previews the selection and opens the confirmation', async () => {
+ apiMock.mockResolvedValue(jsonResponse({ shows: 2, files: 0, bytes: 0 }))
+ const bulkDelete = usePodcastBulkDelete(ref(7))
+
+ const opened = await bulkDelete.prepare([3, 4])
+
+ expect(opened).toBe(true)
+ expect(bulkDelete.open.value).toBe(true)
+ expect(bulkDelete.preview.value).toEqual({ shows: 2, files: 0, bytes: 0 })
+ expect(lastRequest()).toEqual({ url: '/api/v1/podcast-libraries/7/shows/bulk-purge-preview', body: { podcastIds: [3, 4] } })
+ })
+
+ it('asks for typed confirmation only when the selection has files to destroy', async () => {
+ apiMock.mockResolvedValue(jsonResponse({ shows: 1, files: 0, bytes: 0 }))
+ const bulkDelete = usePodcastBulkDelete(ref(7))
+ await bulkDelete.prepare([3])
+ expect(bulkDelete.needsTypedConfirmation.value).toBe(false)
+
+ apiMock.mockResolvedValue(jsonResponse({ shows: 1, files: 4, bytes: 2048 }))
+ await bulkDelete.prepare([3])
+ expect(bulkDelete.needsTypedConfirmation.value).toBe(true)
+ })
+
+ it('does nothing at all for an empty selection', async () => {
+ const bulkDelete = usePodcastBulkDelete(ref(7))
+
+ expect(await bulkDelete.prepare([])).toBe(false)
+ expect(await bulkDelete.confirm([])).toBeNull()
+ expect(apiMock).not.toHaveBeenCalled()
+ })
+
+ it('deletes the ids given at confirm time, not the ones the preview described', async () => {
+ apiMock.mockResolvedValue(jsonResponse({ shows: 2, files: 0, bytes: 0 }))
+ const bulkDelete = usePodcastBulkDelete(ref(7))
+ await bulkDelete.prepare([3, 4])
+
+ apiMock.mockResolvedValue(jsonResponse({ completed: 1, failed: 0, skipped: 0 }))
+ const result = await bulkDelete.confirm([4])
+
+ expect(result).toEqual({ completed: 1, failed: 0, skipped: 0 })
+ expect(lastRequest()).toEqual({ url: '/api/v1/podcast-libraries/7/shows/bulk-delete', body: { podcastIds: [4] } })
+ })
+
+ it('closes and clears the preview once the delete is queued', async () => {
+ apiMock.mockResolvedValue(jsonResponse({ shows: 1, files: 0, bytes: 0 }))
+ const bulkDelete = usePodcastBulkDelete(ref(7))
+ await bulkDelete.prepare([3])
+
+ apiMock.mockResolvedValue(jsonResponse({ completed: 1, failed: 0, skipped: 0 }))
+ await bulkDelete.confirm([3])
+
+ expect(bulkDelete.open.value).toBe(false)
+ expect(bulkDelete.preview.value).toBeNull()
+ })
+
+ it('keeps the dialog open and reports the failure when the delete is refused', async () => {
+ apiMock.mockResolvedValue(jsonResponse({ shows: 1, files: 0, bytes: 0 }))
+ const bulkDelete = usePodcastBulkDelete(ref(7))
+ await bulkDelete.prepare([3])
+
+ apiMock.mockResolvedValue(jsonResponse({ message: 'nope' }, 409))
+ const result = await bulkDelete.confirm([3])
+
+ expect(result).toBeNull()
+ expect(bulkDelete.open.value).toBe(true)
+ // `errorMessage` resolves through the real catalog rather than the mocked `useI18n`, so this
+ // also proves the key exists.
+ expect(bulkDelete.error.value).toBe('Could not delete the selected shows')
+ expect(bulkDelete.deleting.value).toBe(false)
+ })
+
+ it('leaves the confirmation shut when the preview itself fails', async () => {
+ apiMock.mockResolvedValue(jsonResponse({ message: 'nope' }, 403))
+ const bulkDelete = usePodcastBulkDelete(ref(7))
+
+ expect(await bulkDelete.prepare([3])).toBe(false)
+ expect(bulkDelete.open.value).toBe(false)
+ expect(bulkDelete.error.value).toBe('Could not work out what deleting these shows would remove')
+ })
+
+ it('reports partial failures separately from a clean run', () => {
+ const bulkDelete = usePodcastBulkDelete(ref(7))
+
+ expect(bulkDelete.resultMessage({ completed: 3, failed: 0, skipped: 0 })).toBe('podcast.bulkDelete.queued')
+ expect(bulkDelete.resultMessage({ completed: 2, failed: 1, skipped: 0 })).toBe('podcast.bulkDelete.queuedWithFailures')
+ })
+})
diff --git a/client/src/features/podcast/composables/usePodcastBulkDelete.ts b/client/src/features/podcast/composables/usePodcastBulkDelete.ts
new file mode 100644
index 000000000..5b721c331
--- /dev/null
+++ b/client/src/features/podcast/composables/usePodcastBulkDelete.ts
@@ -0,0 +1,95 @@
+import { computed, ref, type Ref } from 'vue'
+import { useI18n } from 'vue-i18n'
+import type { PodcastBulkActionResult, PodcastBulkPurgePreview } from '@bookorbit/types'
+import { apiJson, errorMessage, jsonBody } from '@/lib/api-json'
+import { formatBytes } from '@/lib/formatting'
+
+/**
+ * Permanently deletes a selection of shows, in two steps so the confirmation can name what it is
+ * about to destroy. The preview is what decides how much confirmation is asked for: a selection with
+ * cached files on disk escalates to typed input, exactly as deleting one show already does, while a
+ * selection of shows whose folders are already gone needs no more than a click.
+ *
+ * The ids are passed to `confirm` rather than captured at `open`, so a selection the user adjusts
+ * while the dialog is open cannot delete a set the preview never described.
+ */
+export function usePodcastBulkDelete(libraryId: Ref) {
+ const { t } = useI18n()
+
+ const open = ref(false)
+ const preview = ref(null)
+ const preparing = ref(false)
+ const deleting = ref(false)
+ const error = ref(null)
+
+ const needsTypedConfirmation = computed(() => (preview.value?.bytes ?? 0) > 0)
+
+ /** Names what is about to go, so the confirmation is about this selection rather than deletion in general. */
+ const description = computed(() => {
+ const current = preview.value
+ if (!current) return ''
+ return current.bytes > 0
+ ? t('podcast.bulkDelete.descriptionWithFiles', { count: current.shows, size: formatBytes(current.bytes) })
+ : t('podcast.bulkDelete.description', { count: current.shows })
+ })
+
+ async function prepare(podcastIds: number[]): Promise {
+ if (podcastIds.length === 0) return false
+ preparing.value = true
+ error.value = null
+ preview.value = null
+ try {
+ preview.value = await apiJson(
+ `/api/v1/podcast-libraries/${libraryId.value}/shows/bulk-purge-preview`,
+ jsonBody('POST', { podcastIds }),
+ 'podcast.bulkDelete.errors.prepare',
+ )
+ open.value = true
+ return true
+ } catch (reason) {
+ error.value = errorMessage(reason, 'podcast.bulkDelete.errors.prepare')
+ return false
+ } finally {
+ preparing.value = false
+ }
+ }
+
+ async function confirm(podcastIds: number[]): Promise {
+ if (podcastIds.length === 0) return null
+ deleting.value = true
+ error.value = null
+ try {
+ const result = await apiJson(
+ `/api/v1/podcast-libraries/${libraryId.value}/shows/bulk-delete`,
+ jsonBody('POST', { podcastIds }),
+ 'podcast.bulkDelete.errors.delete',
+ )
+ close()
+ return result
+ } catch (reason) {
+ error.value = errorMessage(reason, 'podcast.bulkDelete.errors.delete')
+ return null
+ } finally {
+ deleting.value = false
+ }
+ }
+
+ function close(): void {
+ open.value = false
+ preview.value = null
+ error.value = null
+ }
+
+ /**
+ * What to tell the user once the queue accepted the work. Deletion is a job, so this reports what
+ * was queued rather than claiming the shows are already gone.
+ */
+ function resultMessage(result: PodcastBulkActionResult): string {
+ if (result.failed > 0) {
+ return t('podcast.bulkDelete.queuedWithFailures', { count: result.completed, failed: result.failed })
+ }
+ return t('podcast.bulkDelete.queued', { count: result.completed })
+ }
+
+ return { open, preview, preparing, deleting, error, needsTypedConfirmation, description, prepare, confirm, close, resultMessage }
+}
diff --git a/client/src/features/podcast/composables/usePodcastDownloadBatches.spec.ts b/client/src/features/podcast/composables/usePodcastDownloadBatches.spec.ts
new file mode 100644
index 000000000..a3d295090
--- /dev/null
+++ b/client/src/features/podcast/composables/usePodcastDownloadBatches.spec.ts
@@ -0,0 +1,211 @@
+import { nextTick, ref } from 'vue'
+import { flushPromises } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastDownloadBatch, PodcastDownloadCompleteEvent, PodcastDownloadProgressEvent } from '@bookorbit/types'
+import { api } from '@/lib/api'
+import { usePodcastDownloadBatches } from './usePodcastDownloadBatches'
+import { jsonResponse } from '../test/fixtures'
+
+const mockUsePodcastEvents = vi.hoisted(() => vi.fn<() => unknown>())
+const connected = ref(false)
+let progressCallback: ((event: PodcastDownloadProgressEvent) => void) | null = null
+let completeCallback: ((event: PodcastDownloadCompleteEvent) => void) | null = null
+
+vi.mock('@/lib/api', () => ({ api: vi.fn<(input: RequestInfo | URL) => Promise>() }))
+vi.mock('./usePodcastEvents', () => ({
+ usePodcastEvents: mockUsePodcastEvents,
+}))
+
+const apiMock = vi.mocked(api)
+const downloads = usePodcastDownloadBatches()
+
+describe('usePodcastDownloadBatches', () => {
+ beforeEach(() => {
+ vi.useRealTimers()
+ apiMock.mockReset()
+ connected.value = false
+ progressCallback = null
+ completeCallback = null
+ mockUsePodcastEvents.mockReturnValue({
+ connected,
+ onDownloadProgress: (callback: (event: PodcastDownloadProgressEvent) => void) => {
+ progressCallback = callback
+ return () => {
+ progressCallback = null
+ }
+ },
+ onDownloadComplete: (callback: (event: PodcastDownloadCompleteEvent) => void) => {
+ completeCallback = callback
+ return () => {
+ completeCallback = null
+ }
+ },
+ })
+ downloads.stop()
+ downloads.resetForUserChange()
+ localStorage.clear()
+ })
+
+ afterEach(() => downloads.stop())
+
+ it('restores bounded active batches and refreshes them after a reconnect', async () => {
+ apiMock.mockResolvedValue(jsonResponse([batch()]))
+
+ downloads.start()
+ await flushPromises()
+
+ expect(downloads.batches.value).toHaveLength(1)
+ expect(downloads.batches.value[0]).toMatchObject({ podcastTitle: 'Orbit Radio', total: 2, downloading: 1 })
+
+ connected.value = true
+ await nextTick()
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledTimes(2)
+ expect(apiMock).toHaveBeenLastCalledWith('/api/v1/podcast-download-batches')
+ })
+
+ it('updates byte and terminal progress from realtime events and removes a successful batch after a grace period', async () => {
+ vi.useFakeTimers()
+ apiMock.mockResolvedValue(jsonResponse([batch()]))
+ downloads.start()
+ await flushPromises()
+
+ progressCallback?.({
+ libraryId: 7,
+ podcastId: 3,
+ episodeId: 43,
+ batchId: BATCH_ID,
+ status: 'downloading',
+ receivedBytes: 50,
+ totalBytes: 100,
+ })
+
+ expect(downloads.batches.value[0]).toMatchObject({ receivedBytes: 150, totalBytes: 200, downloading: 1 })
+
+ completeCallback?.({
+ libraryId: 7,
+ podcastId: 3,
+ episodeId: 43,
+ batchId: BATCH_ID,
+ jobStatus: 'completed',
+ mediaStatus: 'local',
+ localSizeBytes: 100,
+ })
+
+ expect(downloads.batches.value[0]).toMatchObject({ completed: 2, downloading: 0 })
+ await vi.advanceTimersByTimeAsync(4_999)
+ expect(downloads.batches.value).toHaveLength(1)
+ await vi.advanceTimersByTimeAsync(1)
+ expect(downloads.batches.value).toHaveLength(0)
+ })
+
+ it('keeps retriable failures queued instead of presenting them as terminal failures', async () => {
+ apiMock.mockResolvedValue(jsonResponse([batch()]))
+ downloads.start()
+ await flushPromises()
+
+ completeCallback?.({
+ libraryId: 7,
+ podcastId: 3,
+ episodeId: 43,
+ batchId: BATCH_ID,
+ jobStatus: 'queued',
+ mediaStatus: 'failed',
+ localSizeBytes: null,
+ })
+
+ expect(downloads.batches.value[0]).toMatchObject({ queued: 1, completed: 1, failed: 0 })
+ })
+
+ it("ignores another user's library-wide batch events after the scoped endpoint rejects the batch", async () => {
+ apiMock.mockImplementation(async (input) =>
+ String(input) === '/api/v1/podcast-download-batches' ? jsonResponse([]) : new Response(null, { status: 404 }),
+ )
+ downloads.start()
+ await flushPromises()
+ const event = {
+ libraryId: 7,
+ podcastId: 3,
+ episodeId: 43,
+ batchId: OTHER_BATCH_ID,
+ status: 'downloading' as const,
+ receivedBytes: 25,
+ totalBytes: 100,
+ }
+
+ progressCallback?.(event)
+ progressCallback?.(event)
+ expect(apiMock).toHaveBeenCalledTimes(2)
+ await flushPromises()
+ progressCallback?.(event)
+ await flushPromises()
+
+ expect(apiMock).toHaveBeenCalledTimes(2)
+ expect(apiMock).toHaveBeenLastCalledWith(`/api/v1/podcast-download-batches/${OTHER_BATCH_ID}`)
+ expect(downloads.batches.value).toHaveLength(0)
+ })
+
+ it('does not let a response from the previous user repopulate global batch state', async () => {
+ const pending: Array<(response: Response) => void> = []
+ apiMock.mockImplementation(() => new Promise((resolve) => pending.push(resolve)))
+ downloads.start()
+ expect(pending).toHaveLength(1)
+
+ downloads.resetForUserChange()
+ expect(pending).toHaveLength(2)
+ const current = batch()
+ current.id = OTHER_BATCH_ID
+ current.podcastTitle = 'Current User Show'
+ pending[1]!(jsonResponse([current]))
+ await flushPromises()
+
+ pending[0]!(jsonResponse([batch()]))
+ await flushPromises()
+
+ expect(downloads.batches.value).toHaveLength(1)
+ expect(downloads.batches.value[0]).toMatchObject({ id: OTHER_BATCH_ID, podcastTitle: 'Current User Show' })
+ })
+
+ it('persists dismissed failed batches so reconnects do not reopen them', async () => {
+ const failed = batch()
+ failed.items[1]!.status = 'failed'
+ failed.downloading = 0
+ failed.failed = 1
+ apiMock.mockResolvedValue(jsonResponse([failed]))
+ downloads.start()
+ await flushPromises()
+
+ downloads.dismiss(BATCH_ID)
+ await downloads.loadActive()
+
+ expect(downloads.batches.value).toHaveLength(0)
+ expect(localStorage.getItem('bookorbit:dismissed-podcast-download-batches')).toContain(BATCH_ID)
+ })
+})
+
+const BATCH_ID = '0f31ea6f-03f0-4e3d-ae5c-1467ae7ec115'
+const OTHER_BATCH_ID = 'b4afdd4e-bc3d-42a1-a6eb-f4e8cb6dca0f'
+
+function batch(): PodcastDownloadBatch {
+ return {
+ id: BATCH_ID,
+ libraryId: 7,
+ podcastId: 3,
+ podcastTitle: 'Orbit Radio',
+ total: 2,
+ queued: 0,
+ downloading: 1,
+ completed: 1,
+ failed: 0,
+ cancelled: 0,
+ receivedBytes: 125,
+ totalBytes: null,
+ createdAt: '2026-08-01T12:00:00.000Z',
+ updatedAt: '2026-08-01T12:02:00.000Z',
+ items: [
+ { episodeId: 42, title: 'Launch', status: 'completed', receivedBytes: 100, totalBytes: 100 },
+ { episodeId: 43, title: 'Landing', status: 'downloading', receivedBytes: 25, totalBytes: null },
+ ],
+ }
+}
diff --git a/client/src/features/podcast/composables/usePodcastDownloadBatches.ts b/client/src/features/podcast/composables/usePodcastDownloadBatches.ts
new file mode 100644
index 000000000..b546162fe
--- /dev/null
+++ b/client/src/features/podcast/composables/usePodcastDownloadBatches.ts
@@ -0,0 +1,224 @@
+import { computed, ref, watch, type WatchStopHandle } from 'vue'
+import type { PodcastDownloadBatch, PodcastDownloadBatchItem, PodcastDownloadCompleteEvent, PodcastDownloadProgressEvent } from '@bookorbit/types'
+import { api } from '@/lib/api'
+import { createCoalescedFetch, createRequestGeneration } from '@/lib/async'
+import { usePodcastEvents } from './usePodcastEvents'
+
+const DISMISSED_STORAGE_KEY = 'bookorbit:dismissed-podcast-download-batches'
+const COMPLETED_VISIBILITY_MS = 5_000
+const MAX_DISMISSED_BATCHES = 50
+
+const batches = ref>(new Map())
+const loading = ref(false)
+const dismissed = new Set(readDismissed())
+const ignored = new Set()
+const completionTimers = new Map>()
+const requestGeneration = createRequestGeneration()
+
+let started = false
+let stopConnectedWatch: WatchStopHandle | null = null
+let stopProgress: (() => void) | null = null
+let stopComplete: (() => void) | null = null
+
+const visibleBatches = computed(() => [...batches.value.values()].sort((left, right) => right.createdAt.localeCompare(left.createdAt)))
+
+function readDismissed(): string[] {
+ try {
+ const value = JSON.parse(localStorage.getItem(DISMISSED_STORAGE_KEY) ?? '[]')
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string').slice(-MAX_DISMISSED_BATCHES) : []
+ } catch {
+ return []
+ }
+}
+
+function persistDismissed(): void {
+ try {
+ localStorage.setItem(DISMISSED_STORAGE_KEY, JSON.stringify([...dismissed].slice(-MAX_DISMISSED_BATCHES)))
+ } catch {
+ return
+ }
+}
+
+function replaceBatch(batch: PodcastDownloadBatch): void {
+ if (dismissed.has(batch.id)) return
+ batches.value = new Map(batches.value).set(batch.id, batch)
+ scheduleCompletedRemoval(batch)
+}
+
+function removeBatch(batchId: string): void {
+ const timer = completionTimers.get(batchId)
+ if (timer) clearTimeout(timer)
+ completionTimers.delete(batchId)
+ const next = new Map(batches.value)
+ next.delete(batchId)
+ batches.value = next
+}
+
+function isTerminal(batch: PodcastDownloadBatch): boolean {
+ return batch.queued === 0 && batch.downloading === 0
+}
+
+function scheduleCompletedRemoval(batch: PodcastDownloadBatch): void {
+ const previous = completionTimers.get(batch.id)
+ if (previous) clearTimeout(previous)
+ completionTimers.delete(batch.id)
+ if (!isTerminal(batch) || batch.failed > 0) return
+ completionTimers.set(
+ batch.id,
+ setTimeout(() => removeBatch(batch.id), COMPLETED_VISIBILITY_MS),
+ )
+}
+
+function recalculate(batch: PodcastDownloadBatch): void {
+ batch.total = batch.items.length
+ batch.queued = 0
+ batch.downloading = 0
+ batch.completed = 0
+ batch.failed = 0
+ batch.cancelled = 0
+ batch.receivedBytes = 0
+ batch.totalBytes = 0
+ for (const item of batch.items) {
+ batch[item.status]++
+ batch.receivedBytes += item.receivedBytes
+ batch.totalBytes = batch.totalBytes === null || item.totalBytes === null ? null : batch.totalBytes + item.totalBytes
+ }
+ batch.updatedAt = new Date().toISOString()
+ scheduleCompletedRemoval(batch)
+ batches.value = new Map(batches.value)
+}
+
+function findItem(batch: PodcastDownloadBatch, episodeId: number): PodcastDownloadBatchItem | undefined {
+ return batch.items.find((item) => item.episodeId === episodeId)
+}
+
+const fetchBatchById = createCoalescedFetch(
+ async (batchId: string): Promise => {
+ const generation = requestGeneration.current()
+ try {
+ const response = await api(`/api/v1/podcast-download-batches/${encodeURIComponent(batchId)}`)
+ if (!requestGeneration.isCurrent(generation)) return
+ if (response.ok) replaceBatch((await response.json()) as PodcastDownloadBatch)
+ else if (response.status === 404) {
+ ignored.add(batchId)
+ removeBatch(batchId)
+ }
+ } catch {
+ return
+ }
+ },
+ (batchId) => batchId,
+)
+
+async function fetchBatch(batchId: string): Promise {
+ if (dismissed.has(batchId) || ignored.has(batchId)) return
+ return fetchBatchById(batchId)
+}
+
+const loadActive = createCoalescedFetch(async (): Promise => {
+ const generation = requestGeneration.current()
+ loading.value = true
+ try {
+ const response = await api('/api/v1/podcast-download-batches')
+ if (!response.ok) return
+ const active = (await response.json()) as PodcastDownloadBatch[]
+ if (!requestGeneration.isCurrent(generation)) return
+ const next = new Map()
+ for (const batch of active) {
+ if (!dismissed.has(batch.id)) {
+ ignored.delete(batch.id)
+ next.set(batch.id, batch)
+ }
+ }
+ batches.value = next
+ } catch {
+ return
+ } finally {
+ if (requestGeneration.isCurrent(generation)) loading.value = false
+ }
+})
+
+function handleProgress(event: PodcastDownloadProgressEvent): void {
+ if (!event.batchId || dismissed.has(event.batchId) || ignored.has(event.batchId)) return
+ const batch = batches.value.get(event.batchId)
+ const item = batch && findItem(batch, event.episodeId)
+ if (!batch || !item) {
+ void fetchBatch(event.batchId)
+ return
+ }
+ item.status = event.status
+ item.receivedBytes = event.receivedBytes
+ item.totalBytes = event.totalBytes
+ recalculate(batch)
+}
+
+function handleComplete(event: PodcastDownloadCompleteEvent): void {
+ if (!event.batchId || dismissed.has(event.batchId) || ignored.has(event.batchId)) return
+ const batch = batches.value.get(event.batchId)
+ const item = batch && findItem(batch, event.episodeId)
+ if (!batch || !item || !event.jobStatus) {
+ void fetchBatch(event.batchId)
+ return
+ }
+ item.status = event.jobStatus === 'queued' ? 'queued' : event.jobStatus
+ if (item.status === 'completed') {
+ const finalBytes = event.localSizeBytes ?? item.totalBytes
+ if (finalBytes !== null) {
+ item.receivedBytes = finalBytes
+ item.totalBytes = finalBytes
+ }
+ }
+ recalculate(batch)
+}
+
+function start(): void {
+ if (started) return
+ started = true
+ const events = usePodcastEvents()
+ stopProgress = events.onDownloadProgress(handleProgress)
+ stopComplete = events.onDownloadComplete(handleComplete)
+ stopConnectedWatch = watch(events.connected, (connected, wasConnected) => {
+ if (connected && connected !== wasConnected) void loadActive()
+ })
+ void loadActive()
+}
+
+function stop(): void {
+ started = false
+ stopProgress?.()
+ stopComplete?.()
+ stopConnectedWatch?.()
+ stopProgress = null
+ stopComplete = null
+ stopConnectedWatch = null
+}
+
+function dismiss(batchId: string): void {
+ dismissed.add(batchId)
+ persistDismissed()
+ removeBatch(batchId)
+}
+
+async function track(batchId: string | null): Promise {
+ if (!batchId) return
+ dismissed.delete(batchId)
+ ignored.delete(batchId)
+ persistDismissed()
+ await fetchBatch(batchId)
+}
+
+function resetForUserChange(): void {
+ requestGeneration.invalidate()
+ loadActive.clear()
+ fetchBatchById.clear()
+ loading.value = false
+ ignored.clear()
+ for (const timer of completionTimers.values()) clearTimeout(timer)
+ completionTimers.clear()
+ batches.value = new Map()
+ if (started) void loadActive()
+}
+
+export function usePodcastDownloadBatches() {
+ return { batches: visibleBatches, loading, start, stop, dismiss, track, resetForUserChange, loadActive }
+}
diff --git a/client/src/features/podcast/composables/usePodcastEpisodeDetail.ts b/client/src/features/podcast/composables/usePodcastEpisodeDetail.ts
new file mode 100644
index 000000000..1a3faae98
--- /dev/null
+++ b/client/src/features/podcast/composables/usePodcastEpisodeDetail.ts
@@ -0,0 +1,57 @@
+import { computed, ref, watch, type Ref } from 'vue'
+import { useI18n } from 'vue-i18n'
+import type { PodcastEpisodeSummary } from '@bookorbit/types'
+import { apiJson } from '@/lib/api-json'
+import { createRequestGeneration } from '@/lib/async'
+
+/**
+ * `GET /podcast-episodes/:id` for the surfaces that open on top of a list row: the quick view and
+ * the metadata editor. Both need the full episode rather than the list projection, both re-read on
+ * every open because a feed refresh or another editor may have moved the values since the row
+ * rendered, and both render the same loading/error/retry surface around it.
+ */
+export function usePodcastEpisodeDetail(episodeId: Ref, open: Ref) {
+ const { t } = useI18n()
+ const detail = ref(null)
+ const loading = ref(false)
+ const error = ref(null)
+ const generation = createRequestGeneration()
+
+ const loaded = computed(() => !loading.value && error.value === null && detail.value !== null)
+
+ async function load(): Promise {
+ const requestedId = episodeId.value
+ if (!requestedId) return
+ const activeGeneration = generation.begin()
+ loading.value = true
+ error.value = null
+ try {
+ const episode = await apiJson(`/api/v1/podcast-episodes/${requestedId}`, undefined, 'podcast.errors.loadEpisode')
+ if (!generation.isCurrent(activeGeneration)) return
+ detail.value = episode
+ } catch (reason) {
+ if (!generation.isCurrent(activeGeneration)) return
+ detail.value = null
+ error.value = reason instanceof Error ? reason.message : t('podcast.errors.loadEpisode')
+ } finally {
+ if (generation.isCurrent(activeGeneration)) loading.value = false
+ }
+ }
+
+ function reload(): void {
+ void load()
+ }
+
+ watch(
+ [open, episodeId] as const,
+ ([isOpen, id], previous) => {
+ if (!isOpen || !id) return
+ // A different episode must not show the previous one's body while the new read is open.
+ if (!previous || id !== previous[1]) detail.value = null
+ void load()
+ },
+ { immediate: true },
+ )
+
+ return { detail, loading, error, loaded, reload }
+}
diff --git a/client/src/features/podcast/composables/usePodcastEpisodeListActions.spec.ts b/client/src/features/podcast/composables/usePodcastEpisodeListActions.spec.ts
new file mode 100644
index 000000000..762a9939a
--- /dev/null
+++ b/client/src/features/podcast/composables/usePodcastEpisodeListActions.spec.ts
@@ -0,0 +1,143 @@
+import { effectScope } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PodcastEpisodeListItem } from '@bookorbit/types'
+import { api } from '@/lib/api'
+import { usePodcastEpisodeListActions } from './usePodcastEpisodeListActions'
+
+const playerMocks = vi.hoisted(() => ({
+ episode: { value: null as { id: number } | null },
+ togglePlayback: vi.fn<() => void>(),
+ playInline: vi.fn<(episodeId: number) => Promise>(async () => undefined),
+ queueEpisodeNext: vi.fn<(episodeId: number) => Promise>(async () => undefined),
+}))
+const queueMocks = vi.hoisted(() => ({
+ add: vi.fn<(episodeId: number) => Promise>(async () => undefined),
+ remove: vi.fn<(episodeId: number) => Promise>(async () => undefined),
+}))
+const toastMocks = vi.hoisted(() => ({ success: vi.fn<() => void>(), error: vi.fn<() => void>() }))
+const push = vi.fn<() => void>()
+
+vi.mock('@/lib/api', () => ({ api: vi.fn<(url: string, init?: RequestInit) => Promise