feat(koreader): hide audiobook-only books from the KOReader plugin

Add a per-login setting, on by default, that leaves books whose content files are all audio out
of the plugin catalog, its counts and bulk downloads, and drops a library holding nothing else.
The plugin detail page also names the first file the device can open as the main file.

Closes #1345
This commit is contained in:
neon
2026-10-20 16:49:58 +00:00
parent a728bf293b
commit b95a285c1b
30 changed files with 25547 additions and 127 deletions
@@ -18,6 +18,7 @@ function makeCredentials(overrides: Partial<KoreaderCredentials> = {}): Koreader
return {
username: 'reader-user',
syncEnabled: true,
hideAudioOnlyBooks: true,
createdAt: '2026-01-01T00:00:00.000Z',
...overrides,
}
@@ -176,6 +176,7 @@ onUnmounted(() => {
const syncUrl = computed(() => new URL('/api/v1/koreader', getSyncUrl()).toString())
const { supported: deliverySupported } = useReadAloudDelivery('koreader-delivery-settings')
const newReadAloudDelivery = ref<ReadAloudDeliveryPreference>('audioless_epub')
const savingHideAudioOnlyBooks = ref(false)
const hasCredentials = computed(() => !!credentials.value)
const activeDevices = computed(() => (syncStatus.value?.devices ?? []).filter((device) => device.retiredAt === null))
@@ -422,6 +423,18 @@ async function handleToggleSync(newValue: boolean) {
}
}
async function handleToggleHideAudioOnlyBooks(newValue: boolean) {
savingHideAudioOnlyBooks.value = true
try {
await updateCredentials({ hideAudioOnlyBooks: newValue })
toast.success(newValue ? t('settings.reader.koreader.audioOnlyBooksHidden') : t('settings.reader.koreader.audioOnlyBooksShown'))
} catch {
toast.error(t('settings.reader.koreader.toggleAudioOnlyBooksFailed'))
} finally {
savingHideAudioOnlyBooks.value = false
}
}
async function handleDelete() {
try {
await deleteCredentials()
@@ -870,6 +883,32 @@ async function handleDownloadPlugin() {
</div>
</div>
<div class="mb-8">
<p class="settings-group-label">
{{ t('settings.reader.koreader.pluginLibrary') }}
</p>
<div class="settings-card">
<div class="flex flex-col gap-3 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:px-5 md:py-4">
<div class="min-w-0">
<p id="koreader-hide-audio-only-label" class="settings-label">
{{ t('settings.reader.koreader.hideAudioOnlyBooks') }}
</p>
<p id="koreader-hide-audio-only-hint" class="settings-hint">
{{ t('settings.reader.koreader.hideAudioOnlyBooksHint') }}
</p>
</div>
<ToggleSwitch
:model-value="credentials?.hideAudioOnlyBooks ?? true"
:disabled="savingHideAudioOnlyBooks"
aria-labelledby="koreader-hide-audio-only-label"
aria-describedby="koreader-hide-audio-only-hint"
class="self-start md:self-auto"
@update:model-value="handleToggleHideAudioOnlyBooks"
/>
</div>
</div>
</div>
<div class="mb-8">
<p class="settings-group-label">
{{ t('settings.reader.koreader.setup') }}
@@ -17,7 +17,7 @@ const device: KoreaderDeviceInfo = {
function createWrapper() {
return mount(KoreaderDeliverySettings, {
props: {
credentials: { username: 'reader', syncEnabled: true, createdAt: '', readAloudDelivery: 'audioless_epub' },
credentials: { username: 'reader', syncEnabled: true, hideAudioOnlyBooks: true, createdAt: '', readAloudDelivery: 'audioless_epub' },
devices: [device],
sweeps: [],
},
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import KoreaderSettings from '../KoreaderSettings.vue'
import type { BookCard, KoreaderCredentials, KoreaderManualHashLink, KoreaderSyncStatus, KoreaderUnmatchedBook } from '@bookorbit/types'
import { copyToClipboard } from '@/lib/clipboard'
import { toast } from 'vue-sonner'
const routerState = vi.hoisted(() => ({
currentQuery: {} as Record<string, string>,
@@ -78,6 +79,7 @@ function makeCredentials(overrides: Partial<KoreaderCredentials> = {}): Koreader
return {
username: 'reader-user',
syncEnabled: true,
hideAudioOnlyBooks: true,
createdAt: '2026-01-01T00:00:00.000Z',
...overrides,
}
@@ -316,7 +318,7 @@ describe('KoreaderSettings', () => {
expect(koreaderMock.createCredentials).toHaveBeenCalledWith({ username: 'new-reader', password: 'secret1' })
})
it('renders configured status, setup, device, activity, guide, and danger sections', async () => {
it('renders configured status, plugin library, setup, device, activity, guide, and danger sections', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
@@ -326,6 +328,7 @@ describe('KoreaderSettings', () => {
expect(wrapper.findAll('.settings-group-label').map((label) => label.text())).toEqual([
'KOReader Status',
'Plugin Library',
'Setup',
'Devices',
'Plugin Activity',
@@ -562,6 +565,47 @@ describe('KoreaderSettings', () => {
expect(koreaderMock.deleteCredentials).toHaveBeenCalledTimes(1)
})
it('names the audiobook-only switch and saves the reader turning it off', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = status.credentials
koreaderMock.syncStatus.value = status
const wrapper = mountComponent()
await flushPromises()
const toggle = wrapper.get('[aria-labelledby="koreader-hide-audio-only-label"]')
expect(toggle.attributes('role')).toBe('switch')
expect(toggle.attributes('aria-checked')).toBe('true')
expect(wrapper.get('#koreader-hide-audio-only-label').text()).toBe('Hide audiobook-only books')
expect(wrapper.find(`#${toggle.attributes('aria-describedby')}`).exists()).toBe(true)
await toggle.trigger('click')
await flushPromises()
expect(koreaderMock.updateCredentials).toHaveBeenCalledWith({ hideAudioOnlyBooks: false })
expect(vi.mocked(toast.success)).toHaveBeenCalledWith('Audiobook-only books shown in the plugin')
expect(toggle.attributes('disabled')).toBeUndefined()
})
it('reports a failed audiobook-only switch and lets the reader try again', async () => {
const status = makeSyncStatus()
koreaderMock.credentials.value = { ...status.credentials!, hideAudioOnlyBooks: false }
koreaderMock.syncStatus.value = status
koreaderMock.updateCredentials.mockRejectedValueOnce(new Error('offline'))
const wrapper = mountComponent()
await flushPromises()
const toggle = wrapper.get('[aria-labelledby="koreader-hide-audio-only-label"]')
expect(toggle.attributes('aria-checked')).toBe('false')
await toggle.trigger('click')
await flushPromises()
expect(koreaderMock.updateCredentials).toHaveBeenCalledWith({ hideAudioOnlyBooks: true })
expect(vi.mocked(toast.error)).toHaveBeenCalledWith('Failed to update the plugin library setting')
expect(toggle.attributes('disabled')).toBeUndefined()
})
it('opens the unmatched link dialog and submits the selected BookOrbit book', async () => {
const status = makeSyncStatus({ pluginTotals: { ...makeSyncStatus().pluginTotals, unmatchedBooks: 1 } })
koreaderMock.credentials.value = status.credentials
+6
View File
@@ -3407,6 +3407,12 @@
"syncEnabled": "KOReader sync enabled",
"syncDisabled": "KOReader sync disabled",
"toggleSyncFailed": "Failed to toggle sync",
"pluginLibrary": "Plugin Library",
"hideAudioOnlyBooks": "Hide audiobook-only books",
"hideAudioOnlyBooksHint": "KOReader cannot play audio, so books with only audio files stay out of the plugin's lists, search and downloads, as does a library holding nothing else. Books that also have an ebook still show.",
"audioOnlyBooksHidden": "Audiobook-only books hidden in the plugin",
"audioOnlyBooksShown": "Audiobook-only books shown in the plugin",
"toggleAudioOnlyBooksFailed": "Failed to update the plugin library setting",
"syncUrlCopied": "Sync URL copied to clipboard",
"syncUrlCopyFailed": "Failed to copy sync URL",
"statusRefreshed": "Sync status refreshed",