mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user