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",
@@ -124,16 +124,21 @@ function DetailData.files(detail, opts)
if pa ~= pb then return pa < pb end
return false
end)
local by_format = {}
for _i, file in ipairs(list) do
local by_format, opens_at = {}, {}
-- The main file is the first one this reader can open: the primary when it
-- can, so a book whose primary is an audiobook leads with its ebook.
local main
for i, file in ipairs(list) do
local fmt = string.lower(file.format or "")
by_format[fmt] = (by_format[fmt] or 0) + 1
opens_at[i] = AUDIO_FORMATS[fmt] ~= true and (opts.opens == nil or opts.opens(fmt) == true)
if main == nil and opens_at[i] then main = file end
end
local usable, other = {}, {}
for _i, file in ipairs(list) do
for i, file in ipairs(list) do
local fmt = string.lower(file.format or "")
local audio = AUDIO_FORMATS[fmt] == true
local opens = not audio and (opts.opens == nil or opts.opens(fmt) == true)
local opens = opens_at[i]
local read_along = fmt == "epub" and file.sourceMediaOverlayAvailable == true
local text_only = read_along and file.downloadVariant == "audioless_epub"
local on_device = opts.on_device ~= nil and opts.on_device(file) == true
@@ -147,8 +152,8 @@ function DetailData.files(detail, opts)
if audio then title = _("Audiobook")
elseif read_along then title = _("Read-along edition")
elseif not opens then title = T(_("%1 file"), fmt:upper())
elseif by_format[fmt] > 1 then title = file.role == "primary" and _("Main edition") or _("Another edition")
elseif file.role == "primary" then title = _("Main file")
elseif by_format[fmt] > 1 then title = file == main and _("Main edition") or _("Another edition")
elseif file == main then title = _("Main file")
else title = _("Also available") end
local entry = {
file = file,
@@ -97,6 +97,33 @@ assertEqual(other[1].audio, true, "the M4B is audio")
assertEqual(other[2].opens, false, "the AZW3 cannot be opened")
assertEqual(DetailData.fileSubs(usable[1])[1], "Text only here; narration plays in BookOrbit", "the read-along explains itself")
-- The main file is the first this reader can open, so a primary it cannot use
-- never leaves the ebook it can labelled as a spare.
local function titles(entries)
local out = {}
for _, entry in ipairs(entries) do table.insert(out, entry.title) end
return table.concat(out, ", ")
end
local audio_primary, audio_rest = DetailData.files({ files = {
{ format = "epub", role = "content" },
{ format = "m4b", role = "primary", durationSeconds = 3600 },
} }, { opens = opens })
assertEqual(titles(audio_primary), "Main file", "the only ebook beside an audiobook primary is the main file")
assertEqual(titles(audio_rest), "Audiobook", "the audiobook primary is listed apart")
assertEqual(titles((DetailData.files({ files = {
{ format = "mp3", role = "primary" },
{ format = "epub", role = "content" },
{ format = "epub", role = "content" },
} }, { opens = opens }))), "Main edition, Another edition", "two editions behind an audiobook primary still lead with one")
assertEqual(titles((DetailData.files({ files = {
{ format = "azw3", role = "primary" },
{ format = "epub", role = "content" },
} }, { opens = opens }))), "Main file", "a primary this reader cannot open hands the main file on")
assertEqual(titles((DetailData.files({ files = {
{ format = "mobi", role = "content" },
{ format = "epub", role = "primary" },
} }, { opens = opens }))), "Main file, Also available", "a primary this reader opens stays the main file")
-- --------------------------------------------------------------- action
local download = DetailData.primary({}, usable, other)
+3
View File
@@ -7,6 +7,8 @@ export interface KoreaderCredentials {
readAloudDelivery?: ReadAloudDeliveryPreference;
username: string;
syncEnabled: boolean;
/** When true the plugin catalog leaves out books whose only content files are audio. */
hideAudioOnlyBooks: boolean;
createdAt: string;
}
@@ -159,6 +161,7 @@ export interface UpdateKoreaderCredentialsPayload {
username?: string;
password?: string;
syncEnabled?: boolean;
hideAudioOnlyBooks?: boolean;
}
export interface TestKoreaderConnectionResult {
+22
View File
@@ -856,6 +856,28 @@ export const E2E_SUITES = Object.freeze({
...SHARED_DB_AND_HELPER_PATHS,
],
},
"koreader-audio-only-catalog": {
id: "koreader-audio-only-catalog",
name: "KOReader Audiobook-Only Catalog",
timeout: 20,
lane: "full",
description: "KOReader catalog setting that hides books whose only content files are audio",
vitestTarget: "test/koreader-audio-only-catalog.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/koreader-audio-only-catalog-e2e-junit.xml`,
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
changedPaths: [
"server/src/modules/koreader/**",
"server/src/modules/opds/**",
"server/src/modules/dashboard/dashboard.repository.ts",
"server/src/modules/dashboard/dashboard.service.ts",
"server/src/modules/smart-scope/smart-scope.service.ts",
"server/src/common/utils/audio-only-book-sql.utils.ts",
"server/test/koreader-audio-only-catalog.e2e-spec.ts",
"server/test/e2e/reader-state-isolation/**",
...SHARED_DB_AND_HELPER_PATHS,
],
},
"koreader-page-stats": {
id: "koreader-page-stats",
name: "KOReader Page Stats Derivation",
@@ -0,0 +1,27 @@
import { sql } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
import { AUDIO_FORMAT_LIST } from '@bookorbit/types';
import { excludeAudioOnlyBooks } from './audio-only-book-sql.utils';
const dialect = new PgDialect();
describe('excludeAudioOnlyBooks', () => {
it('hides a book only when every content file it has is audio', () => {
const { sql: text, params } = dialect.sqlToQuery(excludeAudioOnlyBooks());
// A scalar aggregate rather than NOT EXISTS, so PostgreSQL cannot flatten it into an anti-join.
expect(text).toMatch(/^\(\(select bool_and\(coalesce\("audio_only_content_file"\."format" in \(/);
expect(text).toContain('from "book_files" "audio_only_content_file"');
expect(text).toContain('"audio_only_content_file"."book_id" = "books"."id"');
expect(text).toMatch(/\) is not true$/);
expect(params).toEqual([...AUDIO_FORMAT_LIST, 'content']);
});
it('correlates to any column holding a book id', () => {
const { sql: text } = dialect.sqlToQuery(excludeAudioOnlyBooks(sql.raw('os.id')));
expect(text).toContain('"audio_only_content_file"."book_id" = os.id');
expect(text).not.toContain('"books"."id"');
});
});
@@ -0,0 +1,26 @@
import { and, eq, inArray, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
import { QueryBuilder, alias } from 'drizzle-orm/pg-core';
import { AUDIO_FORMAT_LIST } from '@bookorbit/types';
import { bookFiles, books } from '../../db/schema';
const contentFile = alias(bookFiles, 'audio_only_content_file');
const AUDIO_FORMATS = [...AUDIO_FORMAT_LIST];
/**
* Keeps every book except one whose content files are all audio. A book with an audiobook beside an
* ebook stays, whichever of them is primary, and so does a book with no content file at all.
*
* Written as a scalar aggregate on purpose. The same test as nested NOT EXISTS gets flattened into
* anti-joins whose row estimate collapses to one, and the nested loop that follows took 59s to count
* 38,000 books with 6,000 audiobooks. A scalar subquery is never flattened: it stays one probe of
* book_files_book_id_idx per book the query already reads, about 1.6µs each on that library.
* `bookId` defaults to the books table and accepts any column holding a book id, such as a CTE's.
*/
export function excludeAudioOnlyBooks(bookId: SQLWrapper = books.id): SQL {
const everyContentFileIsAudio = new QueryBuilder()
.select({ allAudio: sql<boolean>`bool_and(coalesce(${inArray(contentFile.format, AUDIO_FORMATS)}, false))` })
.from(contentFile)
.where(and(sql`${contentFile.bookId} = ${bookId}`, eq(contentFile.role, 'content')));
return sql`(${everyContentFileIsAudio}) is not true`;
}
@@ -0,0 +1 @@
ALTER TABLE "koreader_users" ADD COLUMN "hide_audio_only_books" boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -862,6 +862,13 @@
"when": 1792511452628,
"tag": "0122_add_release_info_url_and_review_hold",
"breakpoints": true
},
{
"idx": 123,
"version": "7",
"when": 1792512356419,
"tag": "0123_add_koreader_hide_audio_only_books",
"breakpoints": true
}
]
}
+2
View File
@@ -32,6 +32,8 @@ export const koreaderUsers = pgTable(
passwordMd5: varchar('password_md5', { length: 32 }),
syncEnabled: boolean('sync_enabled').notNull().default(true),
readAloudDelivery: varchar('read_aloud_delivery', { length: 20 }).$type<'original' | 'audioless_epub'>(),
// KOReader cannot play audio, so the plugin catalog leaves out books whose only content files are audio.
hideAudioOnlyBooks: boolean('hide_audio_only_books').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
@@ -7,10 +7,15 @@ import { DB } from '../../db';
import * as schema from '../../db/schema';
import { audiobookProgress, bookFiles, bookMetadata, books, readingProgress, userBookStatus } from '../../db/schema';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import { excludeAudioOnlyBooks } from '../../common/utils/audio-only-book-sql.utils';
import { seriesIndexSortKey } from '../../common/utils/series-index-sql.utils';
type Db = NodePgDatabase<typeof schema>;
type UpNextInSeriesRow = { id: number };
/** Narrows a shelf's selection for a reader whose device cannot play audio. Absent keeps every book. */
export interface ScrollerSelectionOptions {
excludeAudioOnly?: boolean;
}
type RandomCandidateRow = { sampleIndex: number; id: number };
const AUDIO_FORMATS = BOOK_FORMATS.filter(isAudioFormat);
const CONTINUE_SCROLLER_EXCLUDED_READ_STATUSES = ['unread', 'read', 'skimmed', 'abandoned'] as const satisfies readonly ReadStatus[];
@@ -20,19 +25,28 @@ const DISCOVERY_EXCLUDED_READ_STATUSES = ['reading', 'rereading', 'on_hold', 're
const RANDOM_PIVOT_MULTIPLIER = 3;
const RANDOM_MAX_PIVOTS = 60;
function selectionClauses(options: ScrollerSelectionOptions): SQL[] {
return options.excludeAudioOnly ? [excludeAudioOnlyBooks()] : [];
}
@Injectable()
export class DashboardRepository {
private readonly randomIdBounds = new Map<string, { minId: number; maxId: number; expiresAt: number }>();
constructor(@Inject(DB) private readonly db: Db) {}
async findRecentlyAddedBookIds(accessibleLibraryIds: number[], limit: number, contentFilters?: ContentFilterRules): Promise<number[]> {
async findRecentlyAddedBookIds(
accessibleLibraryIds: number[],
limit: number,
contentFilters?: ContentFilterRules,
options: ScrollerSelectionOptions = {},
): Promise<number[]> {
if (accessibleLibraryIds.length === 0) return [];
const cfClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
const rows = await this.db
.select({ id: books.id })
.from(books)
.where(and(inArray(books.libraryId, accessibleLibraryIds), ...cfClauses))
.where(and(inArray(books.libraryId, accessibleLibraryIds), ...cfClauses, ...selectionClauses(options)))
.orderBy(desc(books.addedAt), desc(books.id))
.limit(limit);
@@ -71,10 +85,11 @@ export class DashboardRepository {
userId: number,
limit: number,
contentFilters?: ContentFilterRules,
options: ScrollerSelectionOptions = {},
): Promise<number[]> {
if (accessibleLibraryIds.length === 0) return [];
const cfClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
const cfClauses = [...(contentFilters ? buildContentFilterClauses(contentFilters, this.db) : []), ...selectionClauses(options)];
const rows = await this.db
.select({ id: books.id })
.from(books)
@@ -125,10 +140,11 @@ export class DashboardRepository {
userId: number,
limit: number,
contentFilters?: ContentFilterRules,
options: ScrollerSelectionOptions = {},
): Promise<number[]> {
if (accessibleLibraryIds.length === 0) return [];
const cfClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
const cfClauses = [...(contentFilters ? buildContentFilterClauses(contentFilters, this.db) : []), ...selectionClauses(options)];
const activity = this.listeningActivity(userId);
const rows = await this.db
.select({ id: books.id })
@@ -223,10 +239,16 @@ export class DashboardRepository {
return rows[0]?.value ?? 0;
}
async findWantToReadBookIds(accessibleLibraryIds: number[], userId: number, limit: number, contentFilters?: ContentFilterRules): Promise<number[]> {
async findWantToReadBookIds(
accessibleLibraryIds: number[],
userId: number,
limit: number,
contentFilters?: ContentFilterRules,
options: ScrollerSelectionOptions = {},
): Promise<number[]> {
if (accessibleLibraryIds.length === 0) return [];
const cfClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
const cfClauses = [...(contentFilters ? buildContentFilterClauses(contentFilters, this.db) : []), ...selectionClauses(options)];
const rows = await this.db
.select({ id: books.id })
.from(books)
@@ -245,6 +267,7 @@ export class DashboardRepository {
userId: number,
limit: number,
contentFilters?: ContentFilterRules,
options: ScrollerSelectionOptions = {},
): Promise<number[]> {
if (accessibleLibraryIds.length === 0) return [];
if (limit <= 0) return [];
@@ -277,6 +300,9 @@ export class DashboardRepository {
sql`, `,
);
const filterSql = cfClauses.length > 0 ? sql`and ${sql.join(cfClauses, sql` and `)}` : sql``;
// Like the read-status gate, this narrows candidates only: an audiobook-only volume still holds
// its place, so the series waits on it rather than offering the volume after it.
const candidateSql = options.excludeAudioOnly ? sql`and ${excludeAudioOnlyBooks(sql.raw('os.id'))}` : sql``;
// The read-status gate applies to candidates only. An abandoned volume stays in the window so
// the series stops there instead of skipping ahead to the volume after it.
@@ -346,6 +372,7 @@ export class DashboardRepository {
and os.is_completed = false
and os.current_progress = 0
and (os.read_status is null or os.read_status in ('unread', 'want_to_read'))
${candidateSql}
order by os.library_id, os.series_id, ${seriesIndexSortKey(sql.raw('os.series_index'))} asc,
os.series_index collate "C" asc, os.added_at asc, os.id asc
)
@@ -358,7 +385,13 @@ export class DashboardRepository {
return rows.rows.map((row) => row.id);
}
async findRandomBookIds(accessibleLibraryIds: number[], userId: number, limit: number, contentFilters?: ContentFilterRules): Promise<number[]> {
async findRandomBookIds(
accessibleLibraryIds: number[],
userId: number,
limit: number,
contentFilters?: ContentFilterRules,
options: ScrollerSelectionOptions = {},
): Promise<number[]> {
if (accessibleLibraryIds.length === 0) return [];
if (limit <= 0) return [];
@@ -393,6 +426,7 @@ export class DashboardRepository {
hasNoProgress,
hasNoExcludedReadStatus,
...cfClauses,
...selectionClauses(options),
)!;
// Independent indexed pivots distribute membership across the id range without
@@ -93,6 +93,42 @@ describe('DashboardService', () => {
vi.clearAllMocks();
});
it('selects shelf ids for other clients with their narrowing, inside the clamped limit', async () => {
const { service, dashboardRepo, libraryService } = makeService();
const user = makeUser({ id: 8 });
libraryService.findAccessibleLibraryIds.mockResolvedValue([10]);
dashboardRepo.findUpNextInSeriesBookIds.mockResolvedValue([4, 2]);
dashboardRepo.findRecentlyAddedBookIds.mockResolvedValue([5]);
await expect(service.getScrollerBookIds(ScrollerType.UP_NEXT_IN_SERIES, user, 500, { excludeAudioOnly: true })).resolves.toEqual([4, 2]);
await expect(service.getScrollerBookIds(ScrollerType.RECENTLY_ADDED, user, 12)).resolves.toEqual([5]);
expect(dashboardRepo.findUpNextInSeriesBookIds).toHaveBeenCalledWith([10], 8, 50, EMPTY_CONTENT_FILTER_RULES, { excludeAudioOnly: true });
expect(dashboardRepo.findRecentlyAddedBookIds).toHaveBeenCalledWith([10], 12, EMPTY_CONTENT_FILTER_RULES, {});
});
it('selects smart-scope shelf ids through the id-only scope query with the narrowing', async () => {
const { service, libraryService, smartScopeService } = makeService();
const user = makeUser({ id: 8, settings: { dashboardConfig: { libraryIds: [11] } } });
libraryService.findAccessibleLibraryIds.mockResolvedValue([10, 11]);
smartScopeService.executeSmartScopeBookIds.mockResolvedValue([7, 3]);
await expect(service.getSmartScopeBookIds(5, user, 12, { excludeAudioOnly: true })).resolves.toEqual([7, 3]);
expect(smartScopeService.executeSmartScopeBookIds).toHaveBeenCalledWith(5, user, 12, [11], { excludeAudioOnly: true });
expect(smartScopeService.executeSmartScope).not.toHaveBeenCalled();
});
it('selects nothing for a smart scope that does not hold books', async () => {
const { service, libraryService, smartScopeService } = makeService();
smartScopeService.findOne.mockResolvedValue({ mediaType: 'podcasts' });
libraryService.findAccessibleLibraryIds.mockResolvedValue([10]);
await expect(service.getSmartScopeBookIds(5, makeUser(), 12)).resolves.toEqual([]);
expect(smartScopeService.executeSmartScopeBookIds).not.toHaveBeenCalled();
});
it('rejects smartScope scroller calls when smartScopeId is missing or invalid', async () => {
const { service, smartScopeService } = makeService();
@@ -124,7 +160,7 @@ describe('DashboardService', () => {
await service.getScroller(ScrollerType.RECENTLY_ADDED, user, 20);
expect(dashboardRepo.findRecentlyAddedBookIds).toHaveBeenCalledWith([200], 20, EMPTY_CONTENT_FILTER_RULES);
expect(dashboardRepo.findRecentlyAddedBookIds).toHaveBeenCalledWith([200], 20, EMPTY_CONTENT_FILTER_RULES, {});
});
it('returns empty list when user has no accessible libraries', async () => {
@@ -160,7 +196,7 @@ describe('DashboardService', () => {
const result = await service.getScroller(ScrollerType.RECENTLY_ADDED, user, 0);
expect(dashboardRepo.findRecentlyAddedBookIds).toHaveBeenCalledWith([100, 200], 1, EMPTY_CONTENT_FILTER_RULES);
expect(dashboardRepo.findRecentlyAddedBookIds).toHaveBeenCalledWith([100, 200], 1, EMPTY_CONTENT_FILTER_RULES, {});
expect(bookReadService.findCardsByBookIds).toHaveBeenCalledWith([9, 3], 5);
expect(dashboardRepo.countBooksAddedThisMonth).toHaveBeenCalledWith([100, 200], EMPTY_CONTENT_FILTER_RULES);
expect(result.books.map((card) => card.id)).toEqual([9, 3]);
@@ -179,7 +215,7 @@ describe('DashboardService', () => {
const result = await service.getScroller(ScrollerType.CONTINUE_READING, user, 500);
expect(dashboardRepo.findContinueReadingBookIds).toHaveBeenCalledWith([301], 9, 50, EMPTY_CONTENT_FILTER_RULES);
expect(dashboardRepo.findContinueReadingBookIds).toHaveBeenCalledWith([301], 9, 50, EMPTY_CONTENT_FILTER_RULES, {});
expect(dashboardRepo.countContinueReadingBooks).toHaveBeenCalledWith([301], 9, EMPTY_CONTENT_FILTER_RULES);
expect(result.books.map((card) => card.id)).toEqual([4]);
expect(result.total).toBe(37);
@@ -194,7 +230,7 @@ describe('DashboardService', () => {
const result = await service.getScroller(ScrollerType.CONTINUE_LISTENING, user, 500);
expect(dashboardRepo.findContinueListeningBookIds).toHaveBeenCalledWith([302, 303], 14, 50, EMPTY_CONTENT_FILTER_RULES);
expect(dashboardRepo.findContinueListeningBookIds).toHaveBeenCalledWith([302, 303], 14, 50, EMPTY_CONTENT_FILTER_RULES, {});
expect(bookReadService.findCardsByBookIds).toHaveBeenCalledWith([6], 14);
expect(result.books.map((card) => card.id)).toEqual([6]);
});
@@ -208,7 +244,7 @@ describe('DashboardService', () => {
const result = await service.getScroller(ScrollerType.WANT_TO_READ, user, 7);
expect(dashboardRepo.findWantToReadBookIds).toHaveBeenCalledWith([404], 21, 7, EMPTY_CONTENT_FILTER_RULES);
expect(dashboardRepo.findWantToReadBookIds).toHaveBeenCalledWith([404], 21, 7, EMPTY_CONTENT_FILTER_RULES, {});
expect(result.books.map((card) => card.id)).toEqual([31, 22]);
});
@@ -221,7 +257,7 @@ describe('DashboardService', () => {
const result = await service.getScroller(ScrollerType.UP_NEXT_IN_SERIES, user, 25);
expect(dashboardRepo.findUpNextInSeriesBookIds).toHaveBeenCalledWith([707], 11, 25, EMPTY_CONTENT_FILTER_RULES);
expect(dashboardRepo.findUpNextInSeriesBookIds).toHaveBeenCalledWith([707], 11, 25, EMPTY_CONTENT_FILTER_RULES, {});
expect(result.books.map((card) => card.id)).toEqual([19, 8]);
// The recursive CTE is not worth materialising twice, so this shelf never reports a total.
expect(result.total).toBeNull();
@@ -236,7 +272,7 @@ describe('DashboardService', () => {
await service.getScroller(ScrollerType.UP_NEXT_IN_SERIES, superuser, 20);
expect(dashboardRepo.findUpNextInSeriesBookIds).toHaveBeenCalledWith([1], 17, 20, undefined);
expect(dashboardRepo.findUpNextInSeriesBookIds).toHaveBeenCalledWith([1], 17, 20, undefined, {});
});
it('clamps up-next-in-series limit to minimum of 1', async () => {
@@ -248,7 +284,7 @@ describe('DashboardService', () => {
await service.getScroller(ScrollerType.UP_NEXT_IN_SERIES, user, 0);
expect(dashboardRepo.findUpNextInSeriesBookIds).toHaveBeenCalledWith([91], 18, 1, EMPTY_CONTENT_FILTER_RULES);
expect(dashboardRepo.findUpNextInSeriesBookIds).toHaveBeenCalledWith([91], 18, 1, EMPTY_CONTENT_FILTER_RULES, {});
});
it('routes random requests to repository and skips card fetch when no ids are returned', async () => {
@@ -258,7 +294,7 @@ describe('DashboardService', () => {
const result = await service.getScroller(ScrollerType.RANDOM, makeUser({ id: 3 }), 20);
expect(dashboardRepo.findRandomBookIds).toHaveBeenCalledWith([901], 3, 20, EMPTY_CONTENT_FILTER_RULES);
expect(dashboardRepo.findRandomBookIds).toHaveBeenCalledWith([901], 3, 20, EMPTY_CONTENT_FILTER_RULES, {});
// Null, not zero. Sizing the pool this shelf samples would anti-join the whole library, so it
// declines to answer rather than reporting an empty row as an empty library.
expect(result).toEqual({ books: [], total: null });
@@ -309,7 +345,7 @@ describe('DashboardService', () => {
);
expect(libraryService.findAccessibleLibraryIds).toHaveBeenCalledOnce();
expect(dashboardRepo.findRecentlyAddedBookIds).toHaveBeenCalledWith([11], 20, EMPTY_CONTENT_FILTER_RULES);
expect(dashboardRepo.findRecentlyAddedBookIds).toHaveBeenCalledWith([11], 20, EMPTY_CONTENT_FILTER_RULES, {});
expect(smartScopeService.executeSmartScopeBookIds).toHaveBeenCalledWith(7, user, 20, [11]);
expect(bookReadService.findCardsByBookIds).not.toHaveBeenCalled();
});
@@ -9,7 +9,7 @@ import { BookCoverStore } from '../book-cover-store/book-cover-store.service';
import { assembleBookCards } from '../book/utils/assemble-book-cards';
import { SmartScopeService } from '../smart-scope/smart-scope.service';
import { LibraryService } from '../library/library.service';
import { DashboardRepository } from './dashboard.repository';
import { DashboardRepository, type ScrollerSelectionOptions } from './dashboard.repository';
import { resolveDashboardLibraryIds } from './dashboard-library-scope';
import { DASHBOARD_SCROLLER_MAX_LIMIT, type DashboardScrollerBatchDto, type DashboardScrollerBatchItemDto } from './dto/dashboard-scroller-batch.dto';
import { ScrollerType } from './dto/scroller-type.enum';
@@ -165,24 +165,39 @@ export class DashboardService {
// Book-id selection without web card assembly lets other clients shape the
// same rows. Smart scopes stay separate because they have their own access path.
async getScrollerBookIds(type: Exclude<ScrollerType, 'smart-scope'>, user: RequestUser, limit: number): Promise<number[]> {
return this.findScrollerBookIds(type, user, Math.min(Math.max(1, limit), DASHBOARD_SCROLLER_MAX_LIMIT));
async getScrollerBookIds(
type: Exclude<ScrollerType, 'smart-scope'>,
user: RequestUser,
limit: number,
options: ScrollerSelectionOptions = {},
): Promise<number[]> {
const accessibleLibraryIds = resolveDashboardLibraryIds(await this.libraryService.findAccessibleLibraryIds(user), user);
return this.findScrollerBookIdsForLibraries(
type,
user,
Math.min(Math.max(1, limit), DASHBOARD_SCROLLER_MAX_LIMIT),
accessibleLibraryIds,
options,
);
}
async getSmartScopeBookIds(smartScopeId: number | undefined, user: RequestUser, limit: number): Promise<number[]> {
async getSmartScopeBookIds(
smartScopeId: number | undefined,
user: RequestUser,
limit: number,
options: ScrollerSelectionOptions = {},
): Promise<number[]> {
const resolvedSmartScopeId = this.assertSmartScopeId(smartScopeId);
const scope = await this.smartScopeService.findOne(resolvedSmartScopeId, user);
if (scope.mediaType !== 'books') return [];
const accessibleLibraryIds = resolveDashboardLibraryIds(await this.libraryService.findAccessibleLibraryIds(user), user);
const result = await this.smartScopeService.executeSmartScope(
return this.smartScopeService.executeSmartScopeBookIds(
resolvedSmartScopeId,
user,
0,
Math.min(Math.max(1, limit), DASHBOARD_SCROLLER_MAX_LIMIT),
undefined,
accessibleLibraryIds,
options,
);
return result.items.map((item) => item.id);
}
private assertSmartScopeId(smartScopeId?: number): number {
@@ -192,33 +207,29 @@ export class DashboardService {
return smartScopeId;
}
private async findScrollerBookIds(type: Exclude<ScrollerType, 'smart-scope'>, user: RequestUser, clampedLimit: number): Promise<number[]> {
const accessibleLibraryIds = resolveDashboardLibraryIds(await this.libraryService.findAccessibleLibraryIds(user), user);
return this.findScrollerBookIdsForLibraries(type, user, clampedLimit, accessibleLibraryIds);
}
private async findScrollerBookIdsForLibraries(
type: Exclude<ScrollerType, 'smart-scope'>,
user: RequestUser,
clampedLimit: number,
accessibleLibraryIds: number[],
options: ScrollerSelectionOptions = {},
): Promise<number[]> {
if (accessibleLibraryIds.length === 0) return [];
const contentFilters = user.isSuperuser ? undefined : user.contentFilters;
switch (type) {
case ScrollerType.RECENTLY_ADDED:
return this.dashboardRepo.findRecentlyAddedBookIds(accessibleLibraryIds, clampedLimit, contentFilters);
return this.dashboardRepo.findRecentlyAddedBookIds(accessibleLibraryIds, clampedLimit, contentFilters, options);
case ScrollerType.CONTINUE_READING:
return this.dashboardRepo.findContinueReadingBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
return this.dashboardRepo.findContinueReadingBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters, options);
case ScrollerType.CONTINUE_LISTENING:
return this.dashboardRepo.findContinueListeningBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
return this.dashboardRepo.findContinueListeningBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters, options);
case ScrollerType.WANT_TO_READ:
return this.dashboardRepo.findWantToReadBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
return this.dashboardRepo.findWantToReadBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters, options);
case ScrollerType.UP_NEXT_IN_SERIES:
return this.dashboardRepo.findUpNextInSeriesBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
return this.dashboardRepo.findUpNextInSeriesBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters, options);
case ScrollerType.RANDOM:
return this.dashboardRepo.findRandomBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
return this.dashboardRepo.findRandomBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters, options);
}
}
}
@@ -38,6 +38,10 @@ export class UpdateKoreaderUserDto {
@IsBoolean()
@IsOptional()
syncEnabled?: boolean;
@IsBoolean()
@IsOptional()
hideAudioOnlyBooks?: boolean;
}
export class TestConnectionDto {
@@ -171,6 +171,7 @@ function makeService(
standaloneFileNamingPattern: string;
} | null = null,
achievementService?: { getCatalogue: MockedFunction<(...args: unknown[]) => Promise<unknown>> },
hidesAudioOnlyBooks = false,
) {
const opdsBookService = {
getAccessibleLibraries: vi.fn().mockResolvedValue([{ id: 1, name: 'Main', bookCount: 99 }]),
@@ -198,6 +199,10 @@ function makeService(
getAccessibleLibraryIds: vi.fn().mockResolvedValue([1]),
getBookManifestPage: vi.fn().mockResolvedValue({ rows: [], hasNext: false }),
getRandomBooks: vi.fn().mockResolvedValue([]),
countVisibleAuthors: vi.fn().mockResolvedValue(0),
countVisibleSeries: vi.fn().mockResolvedValue(0),
findAudioOnlyLibraryIds: vi.fn().mockResolvedValue([]),
findAudioOnlyBookIds: vi.fn().mockResolvedValue(new Set<number>()),
};
const bookService = {
getDetail: vi.fn().mockResolvedValue(makeDetail()),
@@ -317,6 +322,12 @@ function makeService(
getLibraryVersion: vi.fn().mockResolvedValue('lib-v1'),
};
const koreaderService = {
getKoreaderUserDefaultPattern: vi.fn().mockResolvedValue(DEFAULT_KOREADER_DEVICE_PATTERN),
getDeviceFileNamingPattern: vi.fn().mockResolvedValue(deviceOrganization),
hidesAudioOnlyBooks: vi.fn().mockResolvedValue(hidesAudioOnlyBooks),
};
const browseCountsService = {
getCounts: vi.fn().mockResolvedValue({ authors: 812, series: 96, annotations: 40 }),
};
@@ -335,10 +346,7 @@ function makeService(
browseCountsService as never,
recommendationService as never,
{ isCrossPlatformPathSanitizationEnabled: vi.fn().mockResolvedValue(true) } as never,
{
getKoreaderUserDefaultPattern: vi.fn().mockResolvedValue(DEFAULT_KOREADER_DEVICE_PATTERN),
getDeviceFileNamingPattern: vi.fn().mockResolvedValue(deviceOrganization),
} as never,
koreaderService as never,
pluginService as never,
authorImageStorage as never,
undefined,
@@ -357,6 +365,7 @@ function makeService(
browseCountsService,
recommendationService,
pluginService,
koreaderService,
};
}
@@ -445,7 +454,7 @@ describe('KoreaderCatalogService', () => {
expect(opdsBookService.getBooksPage).toHaveBeenCalledWith(7, 'last_read', 1, 5, { readStatus: 'reading' }, false, user.contentFilters);
expect(opdsBookService.countBooks).toHaveBeenCalledWith(7, {}, false, user.contentFilters);
expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 12, false, user.contentFilters);
expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 12, false, user.contentFilters, {});
expect(dashboard.sections.map((section) => section.id)).toContain('all-books');
expect(dashboard.username).toBe('testuser');
expect(dashboard.displayName).toBe('Test User');
@@ -541,7 +550,7 @@ describe('KoreaderCatalogService', () => {
const dashboard = await service.getDashboard(user, { type: 'want-to-read' });
expect(dashboardService.getScrollerBookIds).toHaveBeenCalledWith('want-to-read', user, 12);
expect(dashboardService.getScrollerBookIds).toHaveBeenCalledWith('want-to-read', user, 12, {});
expect(opdsBookService.getRandomBooks).not.toHaveBeenCalled();
expect(dashboard.discover).toEqual([]);
expect(dashboard.section).toEqual({ type: 'want-to-read', smartScopeId: null, books: [] });
@@ -747,7 +756,7 @@ describe('KoreaderCatalogService', () => {
const { section } = await service.getDashboardSection(user, { type: 'smart-scope', smartScopeId: 3 });
expect(dashboardService.getSmartScopeBookIds).toHaveBeenCalledWith(3, user, 12);
expect(dashboardService.getSmartScopeBookIds).toHaveBeenCalledWith(3, user, 12, {});
expect(dashboardService.getScrollerBookIds).not.toHaveBeenCalled();
expect(section.smartScopeId).toBe(3);
});
@@ -759,7 +768,7 @@ describe('KoreaderCatalogService', () => {
const { section } = await service.getDashboardSection(user, { type: 'random' });
expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 12, false, user.contentFilters);
expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 12, false, user.contentFilters, {});
expect(dashboardService.getScrollerBookIds).not.toHaveBeenCalled();
expect(section.books.map((book) => book.id)).toEqual([22]);
});
@@ -799,7 +808,7 @@ describe('KoreaderCatalogService', () => {
const result = await service.getDiscover(user);
expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 12, false, user.contentFilters);
expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 12, false, user.contentFilters, {});
expect(result.discover).toHaveLength(1);
expect(result.discover[0]).toEqual(expect.objectContaining({ id: 33, title: 'Frankenstein' }));
});
@@ -986,7 +995,7 @@ describe('KoreaderCatalogService', () => {
const section = await service.getSectionEntries(user, 'collections');
expect(opdsBookService.getUserCollections).toHaveBeenCalledWith(7, false, user.contentFilters);
expect(opdsBookService.getUserCollections).toHaveBeenCalledWith(7, false, user.contentFilters, {});
expect(opdsBookService.countBooks).not.toHaveBeenCalled();
expect(section.items).toEqual([
{
@@ -2151,3 +2160,198 @@ describe('KoreaderCatalogService', () => {
});
});
});
describe('KoreaderCatalogService for a reader hiding audiobook-only books', () => {
const NARROWED = { excludeAudioOnly: true };
function hiding() {
return makeService(null, undefined, true);
}
function booksQuery(overrides: Record<string, unknown> = {}) {
return Object.assign(new KoreaderCatalogBooksQueryDto(), overrides);
}
beforeEach(() => {
vi.clearAllMocks();
mockStat.mockResolvedValue({ size: 1234, mtimeMs: 5000 } as never);
});
it('reads the setting for the requesting reader and leaves a reader who sees them unnarrowed', async () => {
const { service, opdsBookService, koreaderService } = makeService();
const user = makeUser({ id: 7 });
await service.getBooksPage(user, booksQuery({ q: 'dune' }));
expect(koreaderService.hidesAudioOnlyBooks).toHaveBeenCalledWith(7);
expect(opdsBookService.getBooksPage.mock.calls[0]![4]).toEqual({ q: 'dune' });
});
it('narrows book lists, search, jump buckets and the series summary', async () => {
const { service, opdsBookService } = hiding();
const user = makeUser({ id: 7 });
await service.getBooksPage(user, booksQuery({ q: 'dune', seriesId: 42, sort: 'title', buckets: true }));
const filters = { ...NARROWED, q: 'dune', seriesId: 42 };
expect(opdsBookService.getBooksPage.mock.calls[0]![4]).toEqual(filters);
expect(opdsBookService.getBookJumpBuckets).toHaveBeenCalledWith(7, 'title_asc', filters, false, user.contentFilters);
expect(opdsBookService.summarizeBooks).toHaveBeenCalledWith(7, filters, 0, false, user.contentFilters);
});
it('narrows an explicit id list too, so a shelf of selected books never lists a hidden one', async () => {
const { service, opdsBookService } = hiding();
await service.getBooksPage(makeUser({ id: 7 }), booksQuery({ ids: [10, 11] }));
expect(opdsBookService.getBooksPage.mock.calls[0]![4]).toEqual({ ...NARROWED, ids: [10, 11] });
});
it('narrows the bulk manifest without binding its cursor to the setting', async () => {
const seeing = makeService();
const narrowed = hiding();
const user = makeUser({ id: 7 });
seeing.opdsBookService.getBookManifestPage.mockResolvedValueOnce({ rows: [makeManifestRow({ id: 42 })], hasNext: true });
const first = await seeing.service.getBulkManifest(user, makeQuery());
narrowed.opdsBookService.getBookManifestPage.mockResolvedValueOnce({ rows: [], hasNext: false });
const next = await narrowed.service.getBulkManifest(user, makeQuery({ cursor: first.nextCursor! }));
expect(next.restartRequired).toBe(false);
expect(narrowed.opdsBookService.getBookManifestPage).toHaveBeenLastCalledWith(
7,
{ filters: NARROWED, afterId: 42, limit: 100 },
false,
user.contentFilters,
);
});
it('narrows every dashboard book row but the reading history', async () => {
const { service, opdsBookService, dashboardWidgetService } = hiding();
const user = makeUser({ id: 7 });
dashboardWidgetService.getFinishedThisYear.mockResolvedValue([{ bookId: 30, finishedOn: '2026-03-01' }]);
dashboardWidgetService.getYearAgoCandidates.mockResolvedValue([
{ bookId: 31, seconds: 600, days: 2, endProgress: 40, firstDay: '2025-10-10', lastDay: '2025-10-12' },
]);
await service.getDashboard(user);
expect(opdsBookService.getBooksPage).toHaveBeenCalledWith(
7,
'last_read',
1,
5,
{ ...NARROWED, readStatus: 'reading' },
false,
user.contentFilters,
);
expect(opdsBookService.countBooks).toHaveBeenCalledWith(7, NARROWED, false, user.contentFilters);
expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 12, false, user.contentFilters, NARROWED);
// The finished shelf and the year-ago card sit beside a reading goal that counts every book.
expect(opdsBookService.getBooksPage).toHaveBeenCalledWith(7, expect.any(String), 1, 1, { ids: [30] }, false, user.contentFilters);
expect(opdsBookService.getBooksPage).toHaveBeenCalledWith(7, expect.any(String), 1, 1, { ids: [31] }, false, user.contentFilters);
});
it('counts the browse badges over the books the lists show', async () => {
const { service, opdsBookService, browseCountsService } = hiding();
const user = makeUser({ id: 7 });
opdsBookService.getAccessibleLibraryIds.mockResolvedValue([1, 2, 3]);
opdsBookService.findAudioOnlyLibraryIds.mockResolvedValue([2]);
opdsBookService.countVisibleAuthors.mockResolvedValue(40);
opdsBookService.countVisibleSeries.mockResolvedValue(9);
const dashboard = await service.getDashboard(user);
expect(dashboard.browseCounts).toEqual(expect.objectContaining({ libraries: 2, authors: 40, series: 9 }));
expect(browseCountsService.getCounts).not.toHaveBeenCalled();
expect(opdsBookService.findAudioOnlyLibraryIds).toHaveBeenCalledWith([1, 2, 3]);
expect(opdsBookService.countVisibleAuthors).toHaveBeenCalledWith(7, false, user.contentFilters, NARROWED);
expect(opdsBookService.countVisibleSeries).toHaveBeenCalledWith(7, false, user.contentFilters, NARROWED);
expect(opdsBookService.countBooks).toHaveBeenCalledWith(7, { ...NARROWED, readStatus: 'reading' }, false, user.contentFilters);
});
it('keeps the sidebar totals and every library for a reader who sees them', async () => {
const { service, opdsBookService, browseCountsService } = makeService();
const user = makeUser({ id: 7 });
await service.getDashboard(user);
await service.getSectionEntries(user, 'libraries');
expect(browseCountsService.getCounts).toHaveBeenCalledWith(user);
expect(opdsBookService.findAudioOnlyLibraryIds).not.toHaveBeenCalled();
expect(opdsBookService.countVisibleAuthors).not.toHaveBeenCalled();
});
it('selects dashboard sections with the narrowing, so hidden books cannot crowd a row', async () => {
const { service, opdsBookService, dashboardService } = hiding();
const user = makeUser({ id: 7 });
await service.getDashboardSection(user, { type: 'recently-added' });
await service.getDashboardSection(user, { type: 'smart-scope', smartScopeId: 3 });
await service.getDashboardSection(user, { type: 'random' });
await service.getDiscover(user);
expect(dashboardService.getScrollerBookIds).toHaveBeenCalledWith('recently-added', user, 12, NARROWED);
expect(dashboardService.getSmartScopeBookIds).toHaveBeenCalledWith(3, user, 12, NARROWED);
expect(opdsBookService.getRandomBooks).toHaveBeenCalledTimes(2);
expect(opdsBookService.getRandomBooks).toHaveBeenNthCalledWith(2, 7, 12, false, user.contentFilters, NARROWED);
});
it('drops a library holding only audiobook-only books and narrows every shelf count', async () => {
const { service, opdsBookService } = hiding();
const user = makeUser({ id: 7 });
opdsBookService.getAccessibleLibraries.mockResolvedValue([
{ id: 1, name: 'Main', bookCount: 99 },
{ id: 2, name: 'Audiobooks', bookCount: 4 },
]);
opdsBookService.findAudioOnlyLibraryIds.mockResolvedValue([2]);
const libraries = await service.getSectionEntries(user, 'libraries');
await service.getSectionEntries(user, 'collections');
await service.getSectionEntries(user, 'smart-scopes');
expect(libraries.items.map((item) => item.title)).toEqual(['Main']);
expect(opdsBookService.findAudioOnlyLibraryIds).toHaveBeenCalledWith([1, 2]);
expect(opdsBookService.summarizeBooks).toHaveBeenCalledWith(7, { libraryId: 1, ...NARROWED }, 3, false, user.contentFilters);
expect(opdsBookService.summarizeBooks).not.toHaveBeenCalledWith(7, expect.objectContaining({ libraryId: 2 }), 3, false, user.contentFilters);
expect(opdsBookService.getUserCollections).toHaveBeenCalledWith(7, false, user.contentFilters, NARROWED);
expect(opdsBookService.summarizeBooks).toHaveBeenCalledWith(7, { collectionId: 2, ...NARROWED }, 3, false, user.contentFilters);
expect(opdsBookService.summarizeBooks).toHaveBeenCalledWith(7, { smartScopeId: 3, ...NARROWED }, 3, false, user.contentFilters);
});
it('narrows the author and series lists', async () => {
const { service, opdsBookService } = hiding();
const user = makeUser({ id: 7 });
await service.getSectionEntries(user, 'authors', { q: 'her' });
await service.getSectionEntries(user, 'series');
expect(opdsBookService.getDistinctAuthorsPage).toHaveBeenCalledWith(
7,
expect.objectContaining({ q: 'her', ...NARROWED }),
false,
user.contentFilters,
);
expect(opdsBookService.getDistinctSeriesPage).toHaveBeenCalledWith(7, expect.objectContaining(NARROWED), false, user.contentFilters);
});
it('leaves audiobook-only books out of related rows but keeps the open book in its series strip', async () => {
const { service, opdsBookService } = hiding();
opdsBookService.findAudioOnlyBookIds.mockResolvedValue(new Set([10, 11, 13]));
const detail = await service.getBookDetail(makeUser({ id: 7 }), 10);
expect(opdsBookService.findAudioOnlyBookIds).toHaveBeenCalledWith([10, 11, 10, 12, 11, 13]);
expect(detail.seriesMembers.map((member) => member.id)).toEqual([10]);
expect(detail.relatedSections).toEqual([expect.objectContaining({ id: 'author', books: [expect.objectContaining({ id: 12 })] })]);
});
it('looks up no related books for a reader who sees them', async () => {
const { service, opdsBookService } = makeService();
const detail = await service.getBookDetail(makeUser({ id: 7 }), 10);
expect(opdsBookService.findAudioOnlyBookIds).not.toHaveBeenCalled();
expect(detail.seriesMembers.map((member) => member.id)).toEqual([10, 11]);
});
});
@@ -71,7 +71,14 @@ import { AchievementService } from '../achievement/achievement.service';
import { AuthorImageStorageService } from '../authors/author-image-storage.service';
import { fileMimeType } from '../opds/opds-xml.helpers';
import { OpdsBookEntry, OpdsBookService } from '../opds/opds-book.service';
import type { CatalogBucketSortOrder, OpdsBookSummary, OpdsManifestBookRow, OpdsPreviewBookRow, OpdsVisibleAuthor } from '../opds/opds-book.service';
import type {
CatalogBucketSortOrder,
CatalogVisibilityOptions,
OpdsBookSummary,
OpdsManifestBookRow,
OpdsPreviewBookRow,
OpdsVisibleAuthor,
} from '../opds/opds-book.service';
import { RecommendationService } from '../recommendation/recommendation.service';
import { UserBookStatusService } from '../user-book-status/user-book-status.service';
import { KoreaderCatalogBooksQueryDto, KoreaderCatalogManifestQueryDto } from './dto/koreader-catalog-query.dto';
@@ -88,6 +95,10 @@ type CatalogBookFilters = ReturnType<KoreaderCatalogService['buildBookFilters']>
// The progress a book shows, plus when it was last opened: reading_progress.last_read_at
// moves on every open, updated_at only when the position does.
type CatalogProgress = KoreaderCatalogProgress & { lastReadAt: string };
// What this reader asked the plugin to leave out, resolved once per request.
type CatalogVisibility = { excludeAudioOnly: boolean };
// For lists that record what the reader did rather than offer what to read next.
const EVERY_BOOK: CatalogVisibility = { excludeAudioOnly: false };
function latestDate(a: Date | null, b: Date | null): Date | null {
if (!a) return b;
@@ -225,6 +236,7 @@ export class KoreaderCatalogService {
}
async getDashboard(user: RequestUser, section?: KoreaderDashboardSectionConfig): Promise<KoreaderCatalogDashboardResponse> {
const visibility = await this.resolveVisibility(user);
const continueReadingQuery = Object.assign(new KoreaderCatalogBooksQueryDto(), {
page: 1,
size: DASHBOARD_CONTINUE_READING_LIMIT,
@@ -246,15 +258,17 @@ export class KoreaderCatalogService {
finishedThisYear,
achievements,
] = await Promise.all([
this.getBooksPage(user, continueReadingQuery),
section ? Promise.resolve<KoreaderCatalogBookListItem[]>([]) : this.buildDiscover(user),
section ? this.buildDashboardSection(user, section) : Promise.resolve(undefined),
this.loadBooksPage(user, continueReadingQuery, visibility),
section ? Promise.resolve<KoreaderCatalogBookListItem[]>([]) : this.buildDiscover(user, visibility),
section ? this.buildDashboardSection(user, section, visibility) : Promise.resolve(undefined),
this.dashboardWidgetService.getReadingGoal(user),
this.dashboardWidgetService.getReadingStreak(user),
this.dashboardWidgetService.getHighlightOfTheDay(user),
this.countBooks(user, {}),
this.buildBrowseCounts(user),
this.countBooks(user, {}, visibility),
this.buildBrowseCounts(user, visibility),
this.dashboardWidgetService.getReadingDays(user, DASHBOARD_READING_YEAR_DAYS),
// The year-ago card and the finished shelf are the reader's history, which the reading goal
// beside them counts in full, so they keep audiobook-only books.
this.buildYearAgo(user),
this.buildFinishedThisYear(user),
this.buildAchievementSummary(user),
@@ -320,6 +334,7 @@ export class KoreaderCatalogService {
const items = await this.loadBookListItemsByIds(
user,
finished.map((entry) => entry.bookId),
EVERY_BOOK,
);
return items.map((item) => ({ ...item, finishedOn: finishedOn.get(item.id) ?? null }));
}
@@ -331,6 +346,7 @@ export class KoreaderCatalogService {
const items = await this.loadBookListItemsByIds(
user,
candidates.map((candidate) => candidate.bookId),
EVERY_BOOK,
);
const byId = new Map(items.map((item) => [item.id, item]));
for (const candidate of candidates) {
@@ -349,46 +365,62 @@ export class KoreaderCatalogService {
}
async getDiscover(user: RequestUser): Promise<KoreaderCatalogDiscoverResponse> {
return { discover: await this.buildDiscover(user) };
return { discover: await this.buildDiscover(user, await this.resolveVisibility(user)) };
}
async getDashboardSection(user: RequestUser, section: KoreaderDashboardSectionConfig): Promise<KoreaderCatalogDashboardSectionResponse> {
return { section: await this.buildDashboardSection(user, section) };
return { section: await this.buildDashboardSection(user, section, await this.resolveVisibility(user)) };
}
private async buildDashboardSection(user: RequestUser, section: KoreaderDashboardSectionConfig): Promise<KoreaderCatalogDashboardSection> {
private async buildDashboardSection(
user: RequestUser,
section: KoreaderDashboardSectionConfig,
visibility: CatalogVisibility,
): Promise<KoreaderCatalogDashboardSection> {
return {
type: section.type,
smartScopeId: section.smartScopeId ?? null,
books: await this.buildSectionBooks(user, section),
books: await this.buildSectionBooks(user, section, visibility),
};
}
private async buildSectionBooks(user: RequestUser, section: KoreaderDashboardSectionConfig): Promise<KoreaderCatalogBookListItem[]> {
private async buildSectionBooks(
user: RequestUser,
section: KoreaderDashboardSectionConfig,
visibility: CatalogVisibility,
): Promise<KoreaderCatalogBookListItem[]> {
// Random keeps its original implementation so the row and its reroll stay
// byte-for-byte what the Discover row already returned.
if (section.type === 'random') return this.buildDiscover(user);
if (section.type === 'random') return this.buildDiscover(user, visibility);
// Narrowed in the selection itself, so a row of audiobooks cannot crowd out the books behind it.
const narrowing = this.narrowing(visibility);
const bookIds =
section.type === 'smart-scope'
? await this.dashboardService.getSmartScopeBookIds(section.smartScopeId, user, DASHBOARD_SECTION_LIMIT)
: await this.dashboardService.getScrollerBookIds(section.type, user, DASHBOARD_SECTION_LIMIT);
? await this.dashboardService.getSmartScopeBookIds(section.smartScopeId, user, DASHBOARD_SECTION_LIMIT, narrowing)
: await this.dashboardService.getScrollerBookIds(section.type, user, DASHBOARD_SECTION_LIMIT, narrowing);
return this.loadBookListItemsByIds(user, bookIds);
return this.loadBookListItemsByIds(user, bookIds, visibility);
}
// Selection order carries the section's meaning (series position, recency), so
// it is restored after the books query re-sorts by its own criteria.
private async loadBookListItemsByIds(user: RequestUser, bookIds: number[]): Promise<KoreaderCatalogBookListItem[]> {
private async loadBookListItemsByIds(user: RequestUser, bookIds: number[], visibility: CatalogVisibility): Promise<KoreaderCatalogBookListItem[]> {
if (bookIds.length === 0) return [];
const query = Object.assign(new KoreaderCatalogBooksQueryDto(), { page: 1, size: bookIds.length, ids: bookIds });
const { items } = await this.getBooksPage(user, query);
const { items } = await this.loadBooksPage(user, query, visibility);
const byId = new Map(items.map((item) => [item.id, item]));
return bookIds.map((id) => byId.get(id)).filter((item): item is KoreaderCatalogBookListItem => item !== undefined);
}
private async buildDiscover(user: RequestUser): Promise<KoreaderCatalogBookListItem[]> {
const entries = await this.opdsBookService.getRandomBooks(user.id, DASHBOARD_DISCOVER_LIMIT, user.isSuperuser, user.contentFilters);
private async buildDiscover(user: RequestUser, visibility: CatalogVisibility): Promise<KoreaderCatalogBookListItem[]> {
const entries = await this.opdsBookService.getRandomBooks(
user.id,
DASHBOARD_DISCOVER_LIMIT,
user.isSuperuser,
user.contentFilters,
this.narrowing(visibility),
);
if (entries.length === 0) return [];
const bookIds = entries.map((entry) => entry.id);
@@ -402,27 +434,35 @@ export class KoreaderCatalogService {
async getSectionEntries(user: RequestUser, section: string, query: KoreaderCatalogSectionQuery = {}): Promise<KoreaderCatalogSectionResponse> {
switch (section) {
case 'libraries':
return { section, items: await this.getLibraryEntries(user) };
return { section, items: await this.getLibraryEntries(user, await this.resolveVisibility(user)) };
case 'collections':
return { section, items: await this.getCollectionEntries(user) };
return { section, items: await this.getCollectionEntries(user, await this.resolveVisibility(user)) };
case 'smart-scopes':
return { section, items: await this.getSmartScopeEntries(user) };
return { section, items: await this.getSmartScopeEntries(user, await this.resolveVisibility(user)) };
case 'authors':
return this.getAuthorsSection(user, query);
return this.getAuthorsSection(user, query, await this.resolveVisibility(user));
case 'series':
return this.getSeriesSection(user, query);
return this.getSeriesSection(user, query, await this.resolveVisibility(user));
default:
throw new BadRequestException('Unknown catalog section');
}
}
async getBooksPage(user: RequestUser, query: KoreaderCatalogBooksQueryDto): Promise<KoreaderCatalogPage<KoreaderCatalogBookListItem>> {
return this.loadBooksPage(user, query, await this.resolveVisibility(user));
}
private async loadBooksPage(
user: RequestUser,
query: KoreaderCatalogBooksQueryDto,
visibility: CatalogVisibility,
): Promise<KoreaderCatalogPage<KoreaderCatalogBookListItem>> {
const size = query.size ?? 20;
const offset = query.offset ?? ((query.page ?? 1) - 1) * size;
const page = Math.floor(offset / size) + 1;
this.assertPaginationWindow(offset);
const filters = this.buildBookFilters(query);
const filters = this.buildBookFilters(query, visibility);
const sort = this.mapSort(query.sort ?? 'recently_added', query.order);
const bucketSort = query.buckets === true ? this.bucketSortOrder(sort) : null;
const [{ entries, total }, buckets, author] = await Promise.all([
@@ -491,7 +531,7 @@ export class KoreaderCatalogService {
// per-file match request.
async getBulkManifest(user: RequestUser, query: KoreaderCatalogManifestQueryDto): Promise<KoreaderCatalogManifestPage> {
const size = query.size ?? MANIFEST_DEFAULT_PAGE_SIZE;
const filters = this.buildBookFilters(query);
const filters = this.buildBookFilters(query, await this.resolveVisibility(user));
const filterKey = this.manifestFilterKey(query, filters);
const manifestVersion = await this.pluginService.getLibraryVersion(user.id);
@@ -621,7 +661,9 @@ export class KoreaderCatalogService {
}
// Identifies the normalized filter a cursor was minted against, so a cursor
// can never be replayed against a different selection.
// can never be replayed against a different selection. The audio-only setting
// stays out of it, as content filters do: it is account state, so a run that
// sees it change carries on under the new value instead of failing.
private manifestFilterKey(query: KoreaderCatalogManifestQueryDto, filters: ReturnType<KoreaderCatalogService['buildBookFilters']>): string {
const parts = [
`ids=${filters.ids ? [...filters.ids].sort((a, b) => a - b).join('.') : ''}`,
@@ -679,7 +721,7 @@ export class KoreaderCatalogService {
async getBookDetail(user: RequestUser, bookId: number, deviceId?: string, variant?: ReadAloudDeliveryVariant): Promise<KoreaderCatalogBookDetail> {
const [detail, related, userDefaultPattern, sanitizeForCrossPlatform] = await Promise.all([
this.bookService.getDetail(bookId, user),
this.buildRelatedSections(user, bookId),
this.resolveVisibility(user).then((visibility) => this.buildRelatedSections(user, bookId, visibility)),
this.koreaderService.getKoreaderUserDefaultPattern(user.id),
this.appSettingsService.isCrossPlatformPathSanitizationEnabled(),
]);
@@ -798,11 +840,17 @@ export class KoreaderCatalogService {
}
}
private async getLibraryEntries(user: RequestUser): Promise<KoreaderCatalogEntry[]> {
const rows = await this.opdsBookService.getAccessibleLibraries(user.id, user.isSuperuser);
private async getLibraryEntries(user: RequestUser, visibility: CatalogVisibility): Promise<KoreaderCatalogEntry[]> {
const accessible = await this.opdsBookService.getAccessibleLibraries(user.id, user.isSuperuser);
// A library of nothing but audiobooks would only ever open empty, so it goes too.
const audioOnly = visibility.excludeAudioOnly
? new Set(await this.opdsBookService.findAudioOnlyLibraryIds(accessible.map((row) => row.id)))
: new Set<number>();
const rows = accessible.filter((row) => !audioOnly.has(row.id));
const extras = await this.loadShelfExtras(
user,
rows.map((row) => ({ libraryId: row.id })),
visibility,
);
return Promise.all(
rows.map(async (row, index) => {
@@ -811,7 +859,7 @@ export class KoreaderCatalogService {
id: String(row.id),
title: row.name,
section: 'libraries' as const,
count: shelf ? shelf.total : await this.countBooks(user, { libraryId: row.id }),
count: shelf ? shelf.total : await this.countBooks(user, { libraryId: row.id }, visibility),
booksHref: this.booksHref({ libraryId: row.id, sort: 'title' }),
...(shelf ? { readingCount: shelf.readingCount, previewBooks: shelf.previewBooks } : {}),
};
@@ -819,11 +867,12 @@ export class KoreaderCatalogService {
);
}
private async getCollectionEntries(user: RequestUser): Promise<KoreaderCatalogEntry[]> {
const rows = await this.opdsBookService.getUserCollections(user.id, user.isSuperuser, user.contentFilters);
private async getCollectionEntries(user: RequestUser, visibility: CatalogVisibility): Promise<KoreaderCatalogEntry[]> {
const rows = await this.opdsBookService.getUserCollections(user.id, user.isSuperuser, user.contentFilters, this.narrowing(visibility));
const extras = await this.loadShelfExtras(
user,
rows.map((row) => ({ collectionId: row.id })),
visibility,
);
return rows.map((row, index) => {
const shelf = extras[index];
@@ -838,11 +887,12 @@ export class KoreaderCatalogService {
});
}
private async getSmartScopeEntries(user: RequestUser): Promise<KoreaderCatalogEntry[]> {
private async getSmartScopeEntries(user: RequestUser, visibility: CatalogVisibility): Promise<KoreaderCatalogEntry[]> {
const rows = await this.opdsBookService.getUserSmartScopes(user.id);
const extras = await this.loadShelfExtras(
user,
rows.map((row) => ({ smartScopeId: row.id })),
visibility,
);
return rows.map((row, index) => {
const shelf = extras[index];
@@ -859,10 +909,10 @@ export class KoreaderCatalogService {
// Cover strip and counts per shelf. Shelves past the cap, and any shelf whose
// scope refuses this reader, come back as null and render as a plain row.
private async loadShelfExtras(user: RequestUser, scopes: CatalogBookFilters[]): Promise<(ShelfExtras | null)[]> {
private async loadShelfExtras(user: RequestUser, scopes: CatalogBookFilters[], visibility: CatalogVisibility): Promise<(ShelfExtras | null)[]> {
const summaries = await mapWithConcurrency(scopes.slice(0, SHELF_EXTRAS_LIMIT), SHELF_EXTRAS_CONCURRENCY, (filters) =>
this.opdsBookService
.summarizeBooks(user.id, filters, PREVIEW_BOOK_LIMIT, user.isSuperuser, user.contentFilters)
.summarizeBooks(user.id, { ...filters, ...this.narrowing(visibility) }, PREVIEW_BOOK_LIMIT, user.isSuperuser, user.contentFilters)
.catch((error: unknown): OpdsBookSummary | null => {
if (error instanceof ForbiddenException) return null;
throw error;
@@ -906,14 +956,18 @@ export class KoreaderCatalogService {
};
}
private async getAuthorsSection(user: RequestUser, query: KoreaderCatalogSectionQuery): Promise<KoreaderCatalogSectionResponse> {
private async getAuthorsSection(
user: RequestUser,
query: KoreaderCatalogSectionQuery,
visibility: CatalogVisibility,
): Promise<KoreaderCatalogSectionResponse> {
const size = query.size ?? AUTHOR_SERIES_PAGE_SIZE;
const sort = query.sort ?? 'name';
const offset = query.offset ?? ((query.page ?? 1) - 1) * size;
const page = Math.floor(offset / size) + 1;
const { items, hasNext, total, buckets } = await this.opdsBookService.getDistinctAuthorsPage(
user.id,
{ q: query.q, limit: size, offset, sort, filter: query.filter },
{ q: query.q, limit: size, offset, sort, filter: query.filter, ...this.narrowing(visibility) },
user.isSuperuser,
user.contentFilters,
);
@@ -939,14 +993,18 @@ export class KoreaderCatalogService {
return this.buildListSectionPage('authors', entries, page, hasNext, query, { total, size, sort, buckets });
}
private async getSeriesSection(user: RequestUser, query: KoreaderCatalogSectionQuery): Promise<KoreaderCatalogSectionResponse> {
private async getSeriesSection(
user: RequestUser,
query: KoreaderCatalogSectionQuery,
visibility: CatalogVisibility,
): Promise<KoreaderCatalogSectionResponse> {
const size = query.size ?? AUTHOR_SERIES_PAGE_SIZE;
const sort = query.sort ?? 'name';
const offset = query.offset ?? ((query.page ?? 1) - 1) * size;
const page = Math.floor(offset / size) + 1;
const { items, hasNext, total, buckets } = await this.opdsBookService.getDistinctSeriesPage(
user.id,
{ q: query.q, limit: size, offset, sort, filter: query.filter },
{ q: query.q, limit: size, offset, sort, filter: query.filter, ...this.narrowing(visibility) },
user.isSuperuser,
user.contentFilters,
);
@@ -1049,25 +1107,43 @@ export class KoreaderCatalogService {
// authors and series reuse the cached ones the web sidebar already asks for,
// and the rest deliberately avoid the list methods behind the section
// endpoints, which aggregate per-entity book counts a badge never reads.
private async buildBrowseCounts(user: RequestUser): Promise<KoreaderCatalogBrowseCounts> {
const [inProgress, sidebarCounts, libraryIds, collections, smartScopes] = await Promise.all([
this.countBooks(user, { readStatus: 'reading' }),
this.browseCountsService.getCounts(user),
this.opdsBookService.getAccessibleLibraryIds(user.id, user.isSuperuser),
// A reader hiding audiobook-only books gets authors and series counted over
// the books the lists show, since the sidebar's totals include the rest.
private async buildBrowseCounts(user: RequestUser, visibility: CatalogVisibility): Promise<KoreaderCatalogBrowseCounts> {
const [inProgress, authorSeriesCounts, libraries, collections, smartScopes] = await Promise.all([
this.countBooks(user, { readStatus: 'reading' }, visibility),
visibility.excludeAudioOnly ? this.countVisibleAuthorsAndSeries(user, visibility) : this.browseCountsService.getCounts(user),
this.countVisibleLibraries(user, visibility),
this.opdsBookService.countUserCollections(user.id),
this.opdsBookService.countUserSmartScopes(user.id),
]);
return {
inProgress,
libraries: libraryIds.length,
authors: sidebarCounts.authors,
series: sidebarCounts.series,
libraries,
authors: authorSeriesCounts.authors,
series: authorSeriesCounts.series,
collections,
smartScopes,
};
}
private async countVisibleAuthorsAndSeries(user: RequestUser, visibility: CatalogVisibility): Promise<{ authors: number; series: number }> {
const narrowing = this.narrowing(visibility);
const [authors, series] = await Promise.all([
this.opdsBookService.countVisibleAuthors(user.id, user.isSuperuser, user.contentFilters, narrowing),
this.opdsBookService.countVisibleSeries(user.id, user.isSuperuser, user.contentFilters, narrowing),
]);
return { authors, series };
}
// Matches the libraries section, which drops a library holding only audiobook-only books.
private async countVisibleLibraries(user: RequestUser, visibility: CatalogVisibility): Promise<number> {
const libraryIds = await this.opdsBookService.getAccessibleLibraryIds(user.id, user.isSuperuser);
if (!visibility.excludeAudioOnly) return libraryIds.length;
return libraryIds.length - (await this.opdsBookService.findAudioOnlyLibraryIds(libraryIds)).length;
}
private async countBooks(
user: RequestUser,
filters: {
@@ -1077,8 +1153,9 @@ export class KoreaderCatalogService {
seriesId?: number;
readStatus?: 'unread' | 'reading' | 'finished';
},
visibility: CatalogVisibility,
): Promise<number> {
return this.opdsBookService.countBooks(user.id, filters, user.isSuperuser, user.contentFilters);
return this.opdsBookService.countBooks(user.id, { ...filters, ...this.narrowing(visibility) }, user.isSuperuser, user.contentFilters);
}
// `total` keeps counting the page's own filters, read status included, while
@@ -1255,12 +1332,21 @@ export class KoreaderCatalogService {
private async buildRelatedSections(
user: RequestUser,
bookId: number,
visibility: CatalogVisibility,
): Promise<{ sections: KoreaderCatalogRelatedSection[]; seriesMembers: KoreaderCatalogSeriesMember[] }> {
const [seriesBooks, authorBooks, similarBooks] = await Promise.all([
const [seriesLookup, authorLookup, similarLookup] = await Promise.all([
this.safeRelatedLookup(() => this.recommendationService.getSeriesBooks(bookId, user)),
this.safeRelatedLookup(() => this.recommendationService.getAuthorBooks(bookId, user)),
this.safeRelatedLookup(() => this.recommendationService.getRecommendations(bookId, user)),
]);
const audioOnly = visibility.excludeAudioOnly
? await this.opdsBookService.findAudioOnlyBookIds([...seriesLookup, ...authorLookup, ...similarLookup].map((book) => book.id))
: new Set<number>();
// The book on screen keeps its own place in its series strip even when it is audiobook-only.
const shown = (book: { id: number }) => book.id === bookId || !audioOnly.has(book.id);
const seriesBooks = seriesLookup.filter(shown);
const authorBooks = authorLookup.filter(shown);
const similarBooks = similarLookup.filter(shown);
const sections: KoreaderCatalogRelatedSection[] = [];
const seriesMembers: KoreaderCatalogSeriesMember[] = seriesBooks.map((book) => ({
@@ -1392,7 +1478,20 @@ export class KoreaderCatalogService {
};
}
private buildBookFilters(query: KoreaderCatalogBooksQueryDto | KoreaderCatalogManifestQueryDto): {
private async resolveVisibility(user: RequestUser): Promise<CatalogVisibility> {
return { excludeAudioOnly: await this.koreaderService.hidesAudioOnlyBooks(user.id) };
}
// Spread into a query's filters: names the narrowing only when there is one, so an unnarrowed
// query reads exactly as it did before the setting existed.
private narrowing(visibility: CatalogVisibility): CatalogVisibilityOptions {
return visibility.excludeAudioOnly ? { excludeAudioOnly: true } : {};
}
private buildBookFilters(
query: KoreaderCatalogBooksQueryDto | KoreaderCatalogManifestQueryDto,
visibility: CatalogVisibility,
): {
libraryId?: number;
collectionId?: number;
smartScopeId?: number;
@@ -1403,8 +1502,10 @@ export class KoreaderCatalogService {
readStatus?: 'unread' | 'reading' | 'finished';
format?: string;
ids?: number[];
excludeAudioOnly?: boolean;
} {
return {
...this.narrowing(visibility),
...(query.libraryId !== undefined ? { libraryId: query.libraryId } : {}),
...(query.collectionId !== undefined ? { collectionId: query.collectionId } : {}),
...(query.smartScopeId !== undefined ? { smartScopeId: query.smartScopeId } : {}),
@@ -109,6 +109,15 @@ export class KoreaderRepository {
});
}
async findKoreaderCatalogPreferences(userId: number): Promise<{ hideAudioOnlyBooks: boolean } | undefined> {
const [row] = await this.db
.select({ hideAudioOnlyBooks: schema.koreaderUsers.hideAudioOnlyBooks })
.from(schema.koreaderUsers)
.where(eq(schema.koreaderUsers.userId, userId))
.limit(1);
return row;
}
async findKoreaderUserByUsername(username: string) {
return this.db.query.koreaderUsers.findFirst({
where: eq(schema.koreaderUsers.username, username),
@@ -43,6 +43,7 @@ function makeKoreaderUserRow(overrides?: Record<string, unknown>) {
passwordHash: 'stored-bcrypt-hash',
passwordMd5: md5Hex('secret'),
syncEnabled: true,
hideAudioOnlyBooks: true,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
...overrides,
};
@@ -52,6 +53,7 @@ describe('KoreaderService', () => {
let service: KoreaderService;
let mockRepo: {
findKoreaderUser: ReturnType<typeof vi.fn>;
findKoreaderCatalogPreferences: ReturnType<typeof vi.fn>;
findKoreaderUserByUsername: ReturnType<typeof vi.fn>;
createKoreaderUser: ReturnType<typeof vi.fn>;
updateKoreaderUser: ReturnType<typeof vi.fn>;
@@ -134,6 +136,7 @@ describe('KoreaderService', () => {
mockRepo = {
findKoreaderUser: vi.fn(),
findKoreaderCatalogPreferences: vi.fn(),
findKoreaderUserByUsername: vi.fn(),
createKoreaderUser: vi.fn(),
updateKoreaderUser: vi.fn(),
@@ -297,6 +300,16 @@ describe('KoreaderService', () => {
});
});
it('stores the audiobook-only catalog setting either way', async () => {
mockRepo.findKoreaderUser.mockResolvedValue(makeKoreaderUserRow());
await service.updateCredentials(7, { hideAudioOnlyBooks: false });
await service.updateCredentials(7, { hideAudioOnlyBooks: true });
expect(mockRepo.updateKoreaderUser).toHaveBeenNthCalledWith(1, 7, { hideAudioOnlyBooks: false });
expect(mockRepo.updateKoreaderUser).toHaveBeenNthCalledWith(2, 7, { hideAudioOnlyBooks: true });
});
it('throws when credentials do not exist', async () => {
mockRepo.findKoreaderUser.mockResolvedValue(null);
@@ -340,11 +353,18 @@ describe('KoreaderService', () => {
await expect(service.getCredentials(7)).resolves.toEqual({
username: 'reader',
syncEnabled: true,
hideAudioOnlyBooks: true,
createdAt: '2026-01-01T00:00:00.000Z',
readAloudDelivery: null,
});
});
it('reports a reader who chose to see audiobook-only books', async () => {
mockRepo.findKoreaderUser.mockResolvedValue(makeKoreaderUserRow({ hideAudioOnlyBooks: false }));
await expect(service.getCredentials(7)).resolves.toMatchObject({ hideAudioOnlyBooks: false });
});
it('returns null when credentials do not exist', async () => {
mockRepo.findKoreaderUser.mockResolvedValue(null);
@@ -352,6 +372,23 @@ describe('KoreaderService', () => {
});
});
describe('hidesAudioOnlyBooks', () => {
it('follows the stored setting', async () => {
mockRepo.findKoreaderCatalogPreferences.mockResolvedValueOnce({ hideAudioOnlyBooks: false });
mockRepo.findKoreaderCatalogPreferences.mockResolvedValueOnce({ hideAudioOnlyBooks: true });
await expect(service.hidesAudioOnlyBooks(7)).resolves.toBe(false);
await expect(service.hidesAudioOnlyBooks(7)).resolves.toBe(true);
expect(mockRepo.findKoreaderCatalogPreferences).toHaveBeenCalledWith(7);
});
it('hides them when the reader has no stored setting', async () => {
mockRepo.findKoreaderCatalogPreferences.mockResolvedValue(undefined);
await expect(service.hidesAudioOnlyBooks(7)).resolves.toBe(true);
});
});
describe('testConnection', () => {
it('returns true when bcrypt validation succeeds', async () => {
mockRepo.findKoreaderUserByUsername.mockResolvedValue(makeKoreaderUserRow({ userId: 7 }));
@@ -123,7 +123,13 @@ export class KoreaderService {
async updateCredentials(
userId: number,
data: { username?: string; password?: string; syncEnabled?: boolean; readAloudDelivery?: ReadAloudDeliveryPreference },
data: {
username?: string;
password?: string;
syncEnabled?: boolean;
hideAudioOnlyBooks?: boolean;
readAloudDelivery?: ReadAloudDeliveryPreference;
},
) {
this.logger.log(`[${CREDENTIALS_EVENT}] [start] userId=${userId} - updating credentials`);
@@ -149,6 +155,8 @@ export class KoreaderService {
updatePayload.syncEnabled = data.syncEnabled;
}
if (data.hideAudioOnlyBooks !== undefined) updatePayload.hideAudioOnlyBooks = data.hideAudioOnlyBooks;
if (Object.keys(updatePayload).length > 0) {
await this.repo.updateKoreaderUser(userId, updatePayload as Parameters<typeof this.repo.updateKoreaderUser>[1]);
}
@@ -169,11 +177,17 @@ export class KoreaderService {
return {
username: row.username,
syncEnabled: row.syncEnabled,
hideAudioOnlyBooks: row.hideAudioOnlyBooks,
createdAt: row.createdAt.toISOString(),
readAloudDelivery: row.readAloudDelivery ?? null,
};
}
/** Whether the plugin catalog leaves out audiobook-only books for this reader. Defaults to hiding them. */
async hidesAudioOnlyBooks(userId: number): Promise<boolean> {
return (await this.repo.findKoreaderCatalogPreferences(userId))?.hideAudioOnlyBooks ?? true;
}
async testConnection(userId: number, username: string, password: string): Promise<boolean> {
const row = await this.repo.findKoreaderUserByUsername(username);
if (!row || row.userId !== userId) return false;
@@ -284,7 +284,7 @@ describe('OpdsBookService', () => {
const listSpy = vi.spyOn(collectionRepository, 'findVisibleForUser').mockResolvedValue([{ id: 4, name: 'Favorites', bookCount: 1 }]);
const countSpy = vi.spyOn(collectionRepository, 'countVisibleForUser').mockResolvedValue(3);
await expect(service.getUserCollections(8, false, filters)).resolves.toEqual([{ id: 4, name: 'Favorites', bookCount: 1 }]);
expect(listSpy).toHaveBeenCalledWith(8, false, filters);
expect(listSpy).toHaveBeenCalledWith(8, false, filters, []);
await expect(service.countUserCollections(8)).resolves.toBe(3);
expect(countSpy).toHaveBeenCalledWith(8);
});
+117 -14
View File
@@ -1,5 +1,5 @@
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { SQL, type SQLWrapper, type Subquery, and, count, eq, gt, inArray, or, sql } from 'drizzle-orm';
import { SQL, type SQLWrapper, type Subquery, and, count, eq, exists, gt, inArray, not, notExists, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -24,6 +24,7 @@ import { BookQueryBuilder } from '../book/book-query-builder.service';
import { isAudioFormat, type ContentFilterRules, type GroupRule } from '@bookorbit/types';
import { rankFileRowsByBook, rankFilesByFormatPriority } from '../../common/utils/primary-file-selection.utils';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import { excludeAudioOnlyBooks } from '../../common/utils/audio-only-book-sql.utils';
import { seriesIndexOrderBy } from '../../common/utils/series-index-sql.utils';
import { OpdsCollectionRepository } from './opds-collection.repository';
@@ -40,8 +41,14 @@ type OpdsBookFilters = {
readStatus?: 'unread' | 'reading' | 'finished';
format?: string;
ids?: number[];
excludeAudioOnly?: boolean;
};
/** Narrows a catalog query to what one reader's device can use. Absent keeps every book. */
export interface CatalogVisibilityOptions {
excludeAudioOnly?: boolean;
}
type SeriesFilter = { seriesId: number } | { normalizedName: string };
type FetchBookEntriesOptions = {
@@ -360,7 +367,8 @@ export class OpdsBookService {
if (filters?.smartScopeId) {
const where = await this.buildSmartScopeWhere(userId, filters.smartScopeId, accessibleIds, contentFilters, filters.q);
return where ? { where } : null;
if (!where) return null;
return { where: filters.excludeAudioOnly ? and(where, excludeAudioOnlyBooks())! : where };
}
const clauses: SQL[] = [inArray(books.libraryId, accessibleIds), eq(books.status, 'present')];
@@ -406,9 +414,20 @@ export class OpdsBookService {
clauses.push(...buildContentFilterClauses(contentFilters, this.db));
}
if (filters?.excludeAudioOnly) clauses.push(excludeAudioOnlyBooks());
return { where: and(...clauses)!, contextSeries: seriesFilter };
}
// Content filters bind every reader but a superuser; the audio-only exclusion is the reader's own
// choice, so it applies to superusers too.
private bookVisibilityClauses(isSuperuser: boolean, contentFilters: ContentFilterRules | undefined, options: CatalogVisibilityOptions): SQL[] {
return [
...(!isSuperuser && contentFilters ? buildContentFilterClauses(contentFilters, this.db) : []),
...(options.excludeAudioOnly ? [excludeAudioOnlyBooks()] : []),
];
}
// Cursor page for the KOReader bulk download manifest. Ordering by the
// immutable book id keeps pagination stable while the library changes
// underneath a long bulk run: a concurrently added book gets a higher id, so
@@ -588,16 +607,22 @@ export class OpdsBookService {
return this.paginatedBookQuery(where!, 'recent', page, size);
}
async getRandomBooks(userId: number, count: number, isSuperuser = false, contentFilters?: ContentFilterRules): Promise<OpdsBookEntry[]> {
async getRandomBooks(
userId: number,
count: number,
isSuperuser = false,
contentFilters?: ContentFilterRules,
options: CatalogVisibilityOptions = {},
): Promise<OpdsBookEntry[]> {
if (count <= 0) return [];
const accessibleIds = await this.getAccessibleLibraryIds(userId, isSuperuser);
if (accessibleIds.length === 0) return [];
const baseClauses: SQL[] = [inArray(books.libraryId, accessibleIds), eq(books.status, 'present')];
if (!isSuperuser && contentFilters) {
baseClauses.push(...buildContentFilterClauses(contentFilters, this.db));
}
const baseFilter = and(...baseClauses)!;
const baseFilter = and(
inArray(books.libraryId, accessibleIds),
eq(books.status, 'present'),
...this.bookVisibilityClauses(isSuperuser, contentFilters, options),
)!;
const idRows = await this.db
.select({ id: books.id })
.from(books)
@@ -655,14 +680,14 @@ export class OpdsBookService {
async getDistinctAuthorsPage(
userId: number,
opts: { q?: string; limit: number; offset: number; sort?: CatalogListSort; filter?: CatalogListFilter },
opts: { q?: string; limit: number; offset: number; sort?: CatalogListSort; filter?: CatalogListFilter } & CatalogVisibilityOptions,
isSuperuser = false,
contentFilters?: ContentFilterRules,
): Promise<OpdsCatalogListPage<OpdsAuthorPageRow>> {
const accessibleIds = await this.getAccessibleLibraryIds(userId, isSuperuser);
if (accessibleIds.length === 0) return { items: [], hasNext: false, total: 0, buckets: [] };
const filterClauses = !isSuperuser && contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
const filterClauses = this.bookVisibilityClauses(isSuperuser, contentFilters, opts);
const sort = opts.sort ?? 'name';
const order = authorListOrder(sort);
const letter = latinJumpBucketExpr(AUTHOR_SORT_KEY);
@@ -736,14 +761,14 @@ export class OpdsBookService {
async getDistinctSeriesPage(
userId: number,
opts: { q?: string; limit: number; offset: number; sort?: CatalogListSort; filter?: CatalogListFilter },
opts: { q?: string; limit: number; offset: number; sort?: CatalogListSort; filter?: CatalogListFilter } & CatalogVisibilityOptions,
isSuperuser = false,
contentFilters?: ContentFilterRules,
): Promise<OpdsCatalogListPage<OpdsSeriesPageRow>> {
const accessibleIds = await this.getAccessibleLibraryIds(userId, isSuperuser);
if (accessibleIds.length === 0) return { items: [], hasNext: false, total: 0, buckets: [] };
const filterClauses = !isSuperuser && contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
const filterClauses = this.bookVisibilityClauses(isSuperuser, contentFilters, opts);
const sort = opts.sort ?? 'name';
const order = seriesListOrder(sort);
const letter = latinJumpBucketExpr(bookSeries.name);
@@ -1109,8 +1134,13 @@ export class OpdsBookService {
return row ?? null;
}
async getUserCollections(userId: number, isSuperuser = false, contentFilters?: ContentFilterRules) {
return this.collectionRepository.findVisibleForUser(userId, isSuperuser, contentFilters);
async getUserCollections(userId: number, isSuperuser = false, contentFilters?: ContentFilterRules, options: CatalogVisibilityOptions = {}) {
return this.collectionRepository.findVisibleForUser(
userId,
isSuperuser,
contentFilters,
options.excludeAudioOnly ? [excludeAudioOnlyBooks()] : [],
);
}
// Badge counts only need the totals, so these skip the per-entity book counts
@@ -1128,6 +1158,79 @@ export class OpdsBookService {
return Number(row?.total ?? 0);
}
// Badge totals for a narrowed catalog. They join books exactly as the author and series pages do,
// so a badge always agrees with the list it opens.
async countVisibleAuthors(
userId: number,
isSuperuser = false,
contentFilters?: ContentFilterRules,
options: CatalogVisibilityOptions = {},
): Promise<number> {
const accessibleIds = await this.getAccessibleLibraryIds(userId, isSuperuser);
if (accessibleIds.length === 0) return 0;
const [row] = await this.db
.select({ total: sql<number>`count(DISTINCT ${bookAuthors.authorId})::int` })
.from(bookAuthors)
.innerJoin(
books,
and(eq(books.id, bookAuthors.bookId), eq(books.status, 'present'), ...this.bookVisibilityClauses(isSuperuser, contentFilters, options)),
)
.where(inArray(books.libraryId, accessibleIds));
return Number(row?.total ?? 0);
}
async countVisibleSeries(
userId: number,
isSuperuser = false,
contentFilters?: ContentFilterRules,
options: CatalogVisibilityOptions = {},
): Promise<number> {
const accessibleIds = await this.getAccessibleLibraryIds(userId, isSuperuser);
if (accessibleIds.length === 0) return 0;
const [row] = await this.db
.select({ total: sql<number>`count(DISTINCT ${bookSeriesMemberships.seriesId})::int` })
.from(bookSeriesMemberships)
.innerJoin(
books,
and(
eq(books.id, bookSeriesMemberships.bookId),
eq(books.status, 'present'),
...this.bookVisibilityClauses(isSuperuser, contentFilters, options),
),
)
.where(inArray(books.libraryId, accessibleIds));
return Number(row?.total ?? 0);
}
/**
* The libraries among `libraryIds` that hold present books but none outside audio: what a reader
* hiding audiobook-only books would only ever see empty. A library with no books is not one.
*/
async findAudioOnlyLibraryIds(libraryIds: number[]): Promise<number[]> {
if (libraryIds.length === 0) return [];
const presentBook = (extra?: SQL) =>
this.db
.select({ one: sql`1` })
.from(books)
.where(and(eq(books.libraryId, libraries.id), eq(books.status, 'present'), extra));
const rows = await this.db
.select({ id: libraries.id })
.from(libraries)
.where(and(inArray(libraries.id, libraryIds), exists(presentBook()), notExists(presentBook(excludeAudioOnlyBooks()))));
return rows.map((row) => row.id);
}
/** The books among `bookIds` whose content files are all audio. */
async findAudioOnlyBookIds(bookIds: number[]): Promise<Set<number>> {
const ids = [...new Set(bookIds)];
if (ids.length === 0) return new Set();
const rows = await this.db
.select({ id: books.id })
.from(books)
.where(and(inArray(books.id, ids), not(excludeAudioOnlyBooks())));
return new Set(rows.map((row) => row.id));
}
// The scope is shared with getBooksPage so a count can never disagree with the
// list it labels, but nothing is fetched or hydrated to produce it.
async countBooks(userId: number, filters?: OpdsBookFilters, isSuperuser = false, contentFilters?: ContentFilterRules): Promise<number> {
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, count, eq, exists, or, sql } from 'drizzle-orm';
import { type SQL, and, count, eq, exists, or, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { ContentFilterRules } from '@bookorbit/types';
@@ -22,7 +22,8 @@ export class OpdsCollectionRepository {
.limit(1);
}
findVisibleForUser(userId: number, isSuperuser: boolean, contentFilters?: ContentFilterRules) {
// bookClauses narrow which member books a collection's count includes, never which collections list.
findVisibleForUser(userId: number, isSuperuser: boolean, contentFilters?: ContentFilterRules, bookClauses: SQL[] = []) {
const libraryAccess = isSuperuser
? undefined
: exists(
@@ -37,7 +38,7 @@ export class OpdsCollectionRepository {
.select({ id: collections.id, name: collections.name, bookCount: sql<number>`count(${books.id})::int` })
.from(collections)
.leftJoin(collectionBooks, eq(collectionBooks.collectionId, collections.id))
.leftJoin(books, and(eq(books.id, collectionBooks.bookId), eq(books.status, 'present'), libraryAccess, ...filterClauses))
.leftJoin(books, and(eq(books.id, collectionBooks.bookId), eq(books.status, 'present'), libraryAccess, ...filterClauses, ...bookClauses))
.where(this.visibleForUser(userId))
.groupBy(collections.id)
.orderBy(collections.name);
@@ -8,6 +8,8 @@ vi.mock('@bookorbit/types', async (importOriginal) => {
};
});
import type { BookQuery } from '@bookorbit/types';
import { sql, type SQL } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
import type { RequestUser } from '../../common/types/request-user';
import type { SmartScope } from '../../db/schema/smart-scopes';
@@ -914,6 +916,28 @@ describe('SmartScopeService', () => {
expect(bookService.executeBooksQuery).not.toHaveBeenCalled();
});
it('executeSmartScopeBookIds adds the audiobook-only exclusion to the scope filter when asked', async () => {
const { service, smartScopeRepo, libraryService, queryBuilder, bookService } = makeService();
smartScopeRepo.findById.mockResolvedValue([
makeSmartScope({
id: 5,
userId: 12,
filter: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'test' }] },
}),
]);
libraryService.findAccessibleLibraryIds.mockResolvedValue([9]);
queryBuilder.buildWhere.mockReturnValue(sql`scope_rule`);
bookService.executeBookIdsQuery.mockResolvedValue([]);
await service.executeSmartScopeBookIds(5, makeUser({ id: 12 }), 20, undefined, { excludeAudioOnly: true });
await service.executeSmartScopeBookIds(5, makeUser({ id: 12 }), 20);
const narrowed = new PgDialect().sqlToQuery(bookService.executeBookIdsQuery.mock.calls[0]![1] as SQL).sql;
expect(narrowed).toContain('scope_rule');
expect(narrowed).toContain('"audio_only_content_file"');
expect(new PgDialect().sqlToQuery(bookService.executeBookIdsQuery.mock.calls[1]![1] as SQL).sql).toBe('scope_rule');
});
it('executeSmartScopeBookIds intersects a caller-provided restriction with accessible libraries', async () => {
const { service, smartScopeRepo, libraryService, queryBuilder, bookService } = makeService();
const smartScope = makeSmartScope({
@@ -1,5 +1,5 @@
import { BadRequestException, ForbiddenException, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common';
import type { SQL } from 'drizzle-orm';
import { and, type SQL } from 'drizzle-orm';
import type {
BookQuery,
@@ -14,6 +14,7 @@ import type {
} from '@bookorbit/types';
import { APP_FEATURES, PODCAST_PLAYLIST_MAX_SAVED } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { excludeAudioOnlyBooks } from '../../common/utils/audio-only-book-sql.utils';
import { mapWithConcurrency } from '../../common/utils/batch.utils';
import { normalizeIconValue } from '../../common/utils/icon-value.utils';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
@@ -340,11 +341,18 @@ export class SmartScopeService {
);
}
async executeSmartScopeBookIds(id: number, user: RequestUser, size: number, libraryIdsRestriction?: readonly number[]): Promise<number[]> {
async executeSmartScopeBookIds(
id: number,
user: RequestUser,
size: number,
libraryIdsRestriction?: readonly number[],
options: { excludeAudioOnly?: boolean } = {},
): Promise<number[]> {
const query: BookQuery = { sort: [], pagination: { page: 0, size } };
const prepared = await this.prepareBooksQuery(id, user, query, libraryIdsRestriction);
if (!prepared) return [];
return this.bookService.executeBookIdsQuery(user.id, prepared.where, prepared.effectiveQuery);
const where = options.excludeAudioOnly ? and(prepared.where, excludeAudioOnlyBooks()) : prepared.where;
return this.bookService.executeBookIdsQuery(user.id, where, prepared.effectiveQuery);
}
async queryBooks(id: number, user: RequestUser, query: BookQuery, libraryIdsRestriction?: readonly number[]): Promise<BooksPage> {
@@ -0,0 +1,325 @@
import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import {
authHeader,
closeReaderStateIsolationE2EContext,
createLibraryWithFolder,
createReaderStateIsolationE2EContext,
createUserAndLogin,
grantLibraryAccess,
type CreatedLibrary,
type ReaderStateIsolationE2EContext,
type TestUserSession,
} from './e2e/reader-state-isolation/reader-state-isolation-harness';
import {
Permission,
type KoreaderCatalogBookDetail,
type KoreaderCatalogDashboardResponse,
type KoreaderCatalogDashboardSectionResponse,
type KoreaderCatalogManifestPage,
type KoreaderCatalogSectionResponse,
type KoreaderCredentials,
} from '@bookorbit/types';
const TOKEN = `audiocat${randomUUID().slice(0, 8)}`;
const KOREADER_USERNAME = `audio-only-${randomUUID().slice(0, 8)}`;
const KOREADER_PASSWORD = 'AudioOnlyDevicePass123';
type FileSpec = { format: string; role?: 'content' | 'cover'; primary?: boolean; mediaOverlay?: boolean };
type BooksPage = { items: { id: number }[]; total: number; seriesSummary?: { total: number } | null };
describe('KOReader catalog hiding audiobook-only books (e2e)', { timeout: 300_000 }, () => {
let ctx!: ReaderStateIsolationE2EContext;
let mixed!: CreatedLibrary;
let audio!: CreatedLibrary;
let empty!: CreatedLibrary;
let reader!: TestUserSession;
let inoCounter = 1_000_000n;
let seriesS!: number;
const books: Record<string, number> = {};
async function authorId(name: string): Promise<number> {
const [existing] = await ctx.db.select({ id: schema.authors.id }).from(schema.authors).where(eq(schema.authors.name, name));
if (existing) return existing.id;
const [created] = await ctx.db.insert(schema.authors).values({ name }).returning({ id: schema.authors.id });
return created!.id;
}
async function seriesId(name: string): Promise<number> {
const [created] = await ctx.db
.insert(schema.bookSeries)
.values({ name, normalizedName: name.toLowerCase() })
.returning({ id: schema.bookSeries.id });
return created!.id;
}
async function seedBook(
target: CreatedLibrary,
key: string,
files: FileSpec[],
opts: { authors?: string[]; series?: { id: number; index: string }; status?: 'reading' | 'read' } = {},
): Promise<number> {
const folder = `${target.folderPath}/${key}-${randomUUID()}`;
const [book] = await ctx.db
.insert(schema.books)
.values({ libraryId: target.libraryId, libraryFolderId: target.libraryFolderId, folderPath: folder, status: 'present' })
.returning({ id: schema.books.id });
const bookId = book!.id;
await ctx.db.insert(schema.bookMetadata).values({
bookId,
title: `${TOKEN} ${key}`,
...(opts.series ? { seriesId: opts.series.id, seriesIndex: opts.series.index } : {}),
});
let primaryFileId: number | null = null;
for (const [index, file] of files.entries()) {
inoCounter += 1n;
const [row] = await ctx.db
.insert(schema.bookFiles)
.values({
bookId,
libraryFolderId: target.libraryFolderId,
absolutePath: `${folder}/file-${index}.${file.format}`,
relPath: `${key}/file-${index}.${file.format}`,
ino: inoCounter,
format: file.format,
role: file.role ?? 'content',
sizeBytes: 2048 + index,
mediaOverlayAvailable: file.mediaOverlay ?? false,
sortOrder: index,
})
.returning({ id: schema.bookFiles.id });
if (file.primary) primaryFileId = row!.id;
}
if (primaryFileId !== null) await ctx.db.update(schema.books).set({ primaryFileId }).where(eq(schema.books.id, bookId));
for (const [displayOrder, name] of (opts.authors ?? []).entries()) {
await ctx.db.insert(schema.bookAuthors).values({ bookId, authorId: await authorId(name), displayOrder });
}
if (opts.series) {
await ctx.db.insert(schema.bookSeriesMemberships).values({ bookId, seriesId: opts.series.id, seriesIndex: opts.series.index });
}
if (opts.status) await ctx.db.insert(schema.userBookStatus).values({ userId: reader.userId, bookId, status: opts.status, source: 'manual' });
books[key] = bookId;
return bookId;
}
async function catalog<T>(path: string): Promise<T> {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/koreader/plugin/catalog${path}`,
headers: { 'x-auth-user': KOREADER_USERNAME, 'x-auth-key': KOREADER_PASSWORD },
});
expect(response.statusCode, `${path}: ${response.body}`).toBe(200);
return response.json() as T;
}
async function setHiding(hideAudioOnlyBooks: unknown): Promise<number> {
const response = await ctx.app.inject({
method: 'PATCH',
url: '/api/v1/koreader/credentials',
headers: authHeader(reader.accessToken),
payload: { hideAudioOnlyBooks },
});
return response.statusCode;
}
async function credentials(): Promise<KoreaderCredentials> {
const response = await ctx.app.inject({ method: 'GET', url: '/api/v1/koreader/credentials', headers: authHeader(reader.accessToken) });
expect(response.statusCode).toBe(200);
return response.json() as KoreaderCredentials;
}
async function allBookIds(): Promise<number[]> {
const page = await catalog<BooksPage>(`/books?size=100&sort=title`);
return page.items.map((item) => item.id).sort((a, b) => a - b);
}
async function manifestIds(): Promise<number[]> {
const ids: number[] = [];
let cursor: string | null = null;
for (;;) {
const page: KoreaderCatalogManifestPage = await catalog<KoreaderCatalogManifestPage>(
`/manifest?size=5${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
);
ids.push(...page.items.map((item) => item.id));
if (!page.hasNext) break;
cursor = page.nextCursor;
}
return ids.sort((a, b) => a - b);
}
function ids(...keys: string[]): number[] {
return keys.map((key) => books[key]!).sort((a, b) => a - b);
}
const READABLE = [
'plain-epub',
'audio-primary-mixed',
'epub-primary-mixed',
'read-along',
'cover-only',
'comic',
'series-s1',
'series-s3',
'series-t2',
];
const AUDIO_ONLY = ['audio-only', 'mp3-folder', 'series-s2', 'series-t1', 'audio-library-m4b', 'audio-library-mp3'];
beforeAll(async () => {
ctx = await createReaderStateIsolationE2EContext();
mixed = await createLibraryWithFolder(ctx, { name: `${TOKEN} Mixed` });
audio = await createLibraryWithFolder(ctx, { name: `${TOKEN} Audiobooks` });
empty = await createLibraryWithFolder(ctx, { name: `${TOKEN} Empty` });
reader = await createUserAndLogin(ctx, { permissions: [Permission.KoreaderSync] });
for (const library of [mixed, audio, empty]) await grantLibraryAccess(ctx, reader.userId, library.libraryId);
const created = await ctx.app.inject({
method: 'POST',
url: '/api/v1/koreader/credentials',
headers: authHeader(reader.accessToken),
payload: { username: KOREADER_USERNAME, password: KOREADER_PASSWORD },
});
expect([200, 201]).toContain(created.statusCode);
seriesS = await seriesId(`${TOKEN} Series S`);
const seriesT = await seriesId(`${TOKEN} Series T`);
const audioSeries = await seriesId(`${TOKEN} Audio Series`);
const shared = `${TOKEN} Shared Author`;
const listenOnly = `${TOKEN} Listen Only Author`;
await seedBook(mixed, 'plain-epub', [{ format: 'epub', primary: true }], { authors: [shared], status: 'reading' });
await seedBook(mixed, 'audio-only', [{ format: 'm4b', primary: true }], { authors: [shared, listenOnly], status: 'reading' });
await seedBook(mixed, 'mp3-folder', [{ format: 'mp3', primary: true }, { format: 'mp3' }, { format: 'mp3' }, { format: 'jpg', role: 'cover' }], {
authors: [listenOnly],
});
await seedBook(mixed, 'audio-primary-mixed', [{ format: 'm4b', primary: true }, { format: 'epub' }], { authors: [shared] });
await seedBook(mixed, 'epub-primary-mixed', [{ format: 'epub', primary: true }, { format: 'm4b' }]);
await seedBook(mixed, 'read-along', [{ format: 'epub', primary: true, mediaOverlay: true }]);
await seedBook(mixed, 'cover-only', [{ format: 'jpg', role: 'cover' }]);
await seedBook(mixed, 'comic', [{ format: 'cbz', primary: true }]);
// Series S: the next volume is audiobook-only, so the series waits on it rather than skipping ahead.
await seedBook(mixed, 'series-s1', [{ format: 'epub', primary: true }], { series: { id: seriesS, index: '1' }, status: 'read' });
await seedBook(mixed, 'series-s2', [{ format: 'm4b', primary: true }], { series: { id: seriesS, index: '2' } });
await seedBook(mixed, 'series-s3', [{ format: 'epub', primary: true }], { series: { id: seriesS, index: '3' } });
// Series T: a listened audiobook-only volume still counts as done, so the ebook after it is next.
await seedBook(mixed, 'series-t1', [{ format: 'm4b', primary: true }], { series: { id: seriesT, index: '1' }, status: 'read' });
await seedBook(mixed, 'series-t2', [{ format: 'epub', primary: true }], { series: { id: seriesT, index: '2' } });
await seedBook(audio, 'audio-library-m4b', [{ format: 'm4b', primary: true }], { authors: [listenOnly] });
await seedBook(audio, 'audio-library-mp3', [{ format: 'mp3', primary: true }, { format: 'mp3' }], {
series: { id: audioSeries, index: '1' },
});
const [collection] = await ctx.db
.insert(schema.collections)
.values({ userId: reader.userId, name: `${TOKEN} Shelf` })
.returning({ id: schema.collections.id });
await ctx.db
.insert(schema.collectionBooks)
.values(['plain-epub', 'audio-only', 'audio-library-m4b'].map((key) => ({ collectionId: collection!.id, bookId: books[key]! })));
await ctx.db.insert(schema.smartScopes).values({
userId: reader.userId,
name: `${TOKEN} Everything`,
filter: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: TOKEN }] },
});
}, 300_000);
afterAll(async () => {
if (ctx) await closeReaderStateIsolationE2EContext(ctx);
});
it('hides audiobook-only books by default for new credentials', async () => {
expect((await credentials()).hideAudioOnlyBooks).toBe(true);
const status = await ctx.app.inject({ method: 'GET', url: '/api/v1/koreader/sync-status', headers: authHeader(reader.accessToken) });
expect((status.json() as { credentials: KoreaderCredentials }).credentials.hideAudioOnlyBooks).toBe(true);
});
it('keeps every book with a non-audio file, whichever file is primary, and drops the rest', async () => {
expect(await allBookIds()).toEqual(ids(...READABLE));
expect(await manifestIds()).toEqual(ids(...READABLE));
const search = await catalog<BooksPage>(`/books?q=${encodeURIComponent(`${TOKEN} audio-only`)}`);
expect(search.total).toBe(0);
const byIds = await catalog<BooksPage>(`/books?ids=${books['plain-epub']},${books['audio-only']}`);
expect(byIds.items.map((item) => item.id)).toEqual([books['plain-epub']]);
const mixedDetail = await catalog<KoreaderCatalogBookDetail>(`/books/${books['audio-primary-mixed']}`);
expect(mixedDetail.files.map((file) => file.format).sort()).toEqual(['epub', 'm4b']);
// A hidden book still opens by id, from history or a stale link.
expect((await catalog<KoreaderCatalogBookDetail>(`/books/${books['audio-only']}`)).id).toBe(books['audio-only']);
});
it('drops a library of only audiobook-only books and narrows every shelf and badge', async () => {
const libraries = await catalog<KoreaderCatalogSectionResponse>('/sections/libraries');
expect(libraries.items.map((item) => [item.title, item.count])).toEqual([
[`${TOKEN} Empty`, 0],
[`${TOKEN} Mixed`, READABLE.length],
]);
const collections = await catalog<KoreaderCatalogSectionResponse>('/sections/collections');
expect(collections.items.map((item) => item.count)).toEqual([1]);
const scopes = await catalog<KoreaderCatalogSectionResponse>('/sections/smart-scopes');
expect(scopes.items.map((item) => item.count)).toEqual([READABLE.length]);
const authors = await catalog<KoreaderCatalogSectionResponse>('/sections/authors?size=60');
expect(authors.items.map((item) => [item.title, item.count])).toEqual([[`${TOKEN} Shared Author`, 2]]);
const series = await catalog<KoreaderCatalogSectionResponse>('/sections/series?size=60');
expect(series.items.map((item) => [item.title, item.count])).toEqual([
[`${TOKEN} Series S`, 2],
[`${TOKEN} Series T`, 1],
]);
const dashboard = await catalog<KoreaderCatalogDashboardResponse>('/dashboard');
expect(dashboard.totalBooks).toBe(READABLE.length);
expect(dashboard.browseCounts).toEqual(expect.objectContaining({ libraries: 2, authors: authors.total, series: series.total, inProgress: 1 }));
expect(dashboard.continueReading.map((item) => item.id)).toEqual([books['plain-epub']]);
expect(dashboard.discover.every((item) => !ids(...AUDIO_ONLY).includes(item.id))).toBe(true);
});
it('narrows the series a page belongs to and the related rows of a detail page', async () => {
const seriesPage = await catalog<BooksPage>(`/books?seriesId=${seriesS}&sort=series`);
expect(seriesPage.items.map((item) => item.id)).toEqual([books['series-s1'], books['series-s3']]);
expect(seriesPage.seriesSummary?.total).toBe(2);
const s1 = await catalog<KoreaderCatalogBookDetail>(`/books/${books['series-s1']}`);
expect(s1.seriesMembers?.map((member) => member.id)).toEqual([books['series-s1'], books['series-s3']]);
const plain = await catalog<KoreaderCatalogBookDetail>(`/books/${books['plain-epub']}`);
const related = plain.relatedSections.flatMap((section) => section.books.map((book) => book.id));
expect(related).not.toContain(books['audio-only']);
});
it('fills dashboard sections from what remains, and an audiobook-only volume holds its series', async () => {
const upNext = await catalog<KoreaderCatalogDashboardSectionResponse>('/dashboard/sections/up-next-in-series');
expect(upNext.section.books.map((book) => book.id)).toEqual([books['series-t2']]);
const recent = await catalog<KoreaderCatalogDashboardSectionResponse>('/dashboard/sections/recently-added');
expect(recent.section.books.map((book) => book.id).sort((a, b) => a - b)).toEqual(ids(...READABLE));
const [scope] = (await catalog<KoreaderCatalogSectionResponse>('/sections/smart-scopes')).items;
const scoped = await catalog<KoreaderCatalogDashboardSectionResponse>(`/dashboard/sections/smart-scope?smartScopeId=${scope!.id}`);
expect(scoped.section.books.map((book) => book.id).sort((a, b) => a - b)).toEqual(ids(...READABLE));
});
it('rejects a setting that is not a boolean and shows everything once turned off', async () => {
expect(await setHiding('no')).toBe(400);
expect(await setHiding(false)).toBe(200);
expect((await credentials()).hideAudioOnlyBooks).toBe(false);
const everything = ids(...READABLE, ...AUDIO_ONLY);
expect(await allBookIds()).toEqual(everything);
expect(await manifestIds()).toEqual(everything);
const libraries = await catalog<KoreaderCatalogSectionResponse>('/sections/libraries');
expect(libraries.items.map((item) => item.title)).toContain(`${TOKEN} Audiobooks`);
const authors = await catalog<KoreaderCatalogSectionResponse>('/sections/authors?size=60');
expect(authors.items.map((item) => item.title)).toContain(`${TOKEN} Listen Only Author`);
const upNext = await catalog<KoreaderCatalogDashboardSectionResponse>('/dashboard/sections/up-next-in-series');
expect(upNext.section.books.map((book) => book.id).sort((a, b) => a - b)).toEqual(ids('series-s2', 'series-t2'));
const dashboard = await catalog<KoreaderCatalogDashboardResponse>('/dashboard');
expect(dashboard.totalBooks).toBe(everything.length);
expect(dashboard.browseCounts.libraries).toBe(3);
const plain = await catalog<KoreaderCatalogBookDetail>(`/books/${books['plain-epub']}`);
expect(plain.relatedSections.flatMap((section) => section.books.map((book) => book.id))).toContain(books['audio-only']);
const s1 = await catalog<KoreaderCatalogBookDetail>(`/books/${books['series-s1']}`);
expect(s1.seriesMembers?.map((member) => member.id)).toEqual([books['series-s1'], books['series-s2'], books['series-s3']]);
expect(await setHiding(true)).toBe(200);
expect(await allBookIds()).toEqual(ids(...READABLE));
});
});