mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
@@ -46,7 +46,7 @@ describe('useBookViewContext', () => {
|
||||
const nav = useBookNavigation()
|
||||
const books = ref<BookCard[]>([makeBook(1), makeBook(2)])
|
||||
const total = ref(4)
|
||||
const loadMore = vi.fn(async () => {
|
||||
const loadMore = vi.fn<() => Promise<void>>(async () => {
|
||||
books.value = [makeBook(1), makeBook(2), makeBook(3), makeBook(4)]
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useAttrs } from 'vue'
|
||||
import { Aperture, BookMarked, ChevronLeft, ChevronRight, RefreshCw, Shuffle, Sparkles } from 'lucide-vue-next'
|
||||
import { Aperture, BookMarked, ChevronLeft, ChevronRight, ListOrdered, RefreshCw, Shuffle, Sparkles } from 'lucide-vue-next'
|
||||
|
||||
import type { BookCard, ScrollerType } from '@bookorbit/types'
|
||||
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
|
||||
@@ -33,6 +33,7 @@ function scrollBy(delta: number) {
|
||||
|
||||
const typeIcon = computed(() => {
|
||||
if (props.type === 'continue-reading') return BookMarked
|
||||
if (props.type === 'up-next-in-series') return ListOrdered
|
||||
if (props.type === 'recently-added') return Sparkles
|
||||
if (props.type === 'smart-scope') return Aperture
|
||||
return Shuffle
|
||||
@@ -130,6 +131,7 @@ function handleBookAction(book: BookCard, action: BookActionType) {
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<template v-if="type === 'continue-reading'">No books in progress yet. Start reading one to see it here.</template>
|
||||
<template v-else-if="type === 'up-next-in-series'">No next-in-series picks yet. Finish a volume to surface the next one.</template>
|
||||
<template v-else-if="type === 'recently-added'">No books in your library yet.</template>
|
||||
<template v-else-if="type === 'smart-scope'">No books match this smartScope.</template>
|
||||
<template v-else>No books found.</template>
|
||||
|
||||
@@ -46,7 +46,7 @@ const {
|
||||
moveDown: widgetMoveDown,
|
||||
} = useDraggableList(widgetDraft)
|
||||
// ── Add / Remove ─────────────────────────────────────────────
|
||||
const ALL_TYPES: ScrollerType[] = ['continue-reading', 'recently-added', 'random', 'smart-scope']
|
||||
const ALL_TYPES: ScrollerType[] = ['continue-reading', 'up-next-in-series', 'recently-added', 'random', 'smart-scope']
|
||||
|
||||
function addScroller() {
|
||||
if (draft.value.length >= MAX_SCROLLERS) return
|
||||
|
||||
@@ -52,8 +52,8 @@ describe('useDashboardConfig', () => {
|
||||
|
||||
addScroller('smart-scope')
|
||||
|
||||
expect(scrollers.value).toHaveLength(4)
|
||||
expect(DEFAULT_SCROLLERS).toHaveLength(3)
|
||||
expect(scrollers.value).toHaveLength(5)
|
||||
expect(DEFAULT_SCROLLERS).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('prunes shelves that reference deleted smart scopes', async () => {
|
||||
@@ -126,6 +126,33 @@ describe('useDashboardConfig', () => {
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the default label for up-next-in-series when a custom label is empty', async () => {
|
||||
const { useDashboardConfig } = await import('../useDashboardConfig')
|
||||
const { scrollers, saveScrollers } = useDashboardConfig()
|
||||
|
||||
saveScrollers([
|
||||
{
|
||||
id: '12',
|
||||
type: 'up-next-in-series',
|
||||
label: ' ',
|
||||
enabled: true,
|
||||
order: 1,
|
||||
limit: 20,
|
||||
},
|
||||
])
|
||||
|
||||
expect(scrollers.value).toEqual([
|
||||
{
|
||||
id: '12',
|
||||
type: 'up-next-in-series',
|
||||
label: 'Up Next in Series',
|
||||
enabled: true,
|
||||
order: 1,
|
||||
limit: 20,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('does not rewrite storage when smart scope prune keeps the same scrollers', async () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: vi.fn<() => Promise<Response>>(),
|
||||
}))
|
||||
|
||||
import { api } from '@/lib/api'
|
||||
import { useDashboardScroller } from '../useDashboardScroller'
|
||||
|
||||
const mockApi = vi.mocked(api)
|
||||
|
||||
function mockResponse(data: unknown, ok = true): Response {
|
||||
return {
|
||||
ok,
|
||||
json: async () => data,
|
||||
} as Response
|
||||
}
|
||||
|
||||
function mountComposable(type: 'continue-reading' | 'up-next-in-series' | 'smart-scope', limit = 20, smartScopeId?: number) {
|
||||
let result!: ReturnType<typeof useDashboardScroller>
|
||||
mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = useDashboardScroller(type, limit, smartScopeId)
|
||||
return () => null
|
||||
},
|
||||
}),
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
describe('useDashboardScroller', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('loads books for up-next-in-series on mount', async () => {
|
||||
mockApi.mockResolvedValue(mockResponse([{ id: 7 }, { id: 2 }]))
|
||||
const state = mountComposable('up-next-in-series', 12)
|
||||
|
||||
expect(state.loading.value).toBe(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/dashboard/scrollers/up-next-in-series?limit=12')
|
||||
expect(state.books.value).toEqual([{ id: 7 }, { id: 2 }])
|
||||
expect(state.error.value).toBe(false)
|
||||
expect(state.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('includes smartScopeId only for smart-scope requests', async () => {
|
||||
mockApi.mockResolvedValue(mockResponse([]))
|
||||
mountComposable('smart-scope', 30, 99)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/dashboard/scrollers/smart-scope?limit=30&smartScopeId=99')
|
||||
})
|
||||
|
||||
it('sets error=true when API response is not ok', async () => {
|
||||
mockApi.mockResolvedValue(mockResponse([], false))
|
||||
const state = mountComposable('continue-reading', 5)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(state.books.value).toEqual([])
|
||||
expect(state.error.value).toBe(true)
|
||||
expect(state.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('refresh retries after an error and updates books', async () => {
|
||||
mockApi.mockResolvedValueOnce(mockResponse([], false)).mockResolvedValueOnce(mockResponse([{ id: 42 }], true))
|
||||
const state = mountComposable('continue-reading', 5)
|
||||
|
||||
await flushPromises()
|
||||
expect(state.error.value).toBe(true)
|
||||
|
||||
await state.refresh()
|
||||
await flushPromises()
|
||||
|
||||
expect(state.error.value).toBe(false)
|
||||
expect(state.books.value).toEqual([{ id: 42 }])
|
||||
})
|
||||
})
|
||||
@@ -7,12 +7,14 @@ const MAX_SCROLLERS = 6
|
||||
|
||||
export const DEFAULT_SCROLLERS: ScrollerConfig[] = [
|
||||
{ id: '1', type: 'continue-reading', label: 'Continue Reading', enabled: false, order: 1, limit: 20 },
|
||||
{ id: '2', type: 'recently-added', label: 'Recently Added', enabled: true, order: 2, limit: 20 },
|
||||
{ id: '3', type: 'random', label: 'Discover Something New', enabled: true, order: 3, limit: 20 },
|
||||
{ id: '4', type: 'up-next-in-series', label: 'Up Next in Series', enabled: false, order: 2, limit: 20 },
|
||||
{ id: '2', type: 'recently-added', label: 'Recently Added', enabled: true, order: 3, limit: 20 },
|
||||
{ id: '3', type: 'random', label: 'Discover Something New', enabled: true, order: 4, limit: 20 },
|
||||
]
|
||||
|
||||
export const SCROLLER_LABELS: Record<ScrollerType, string> = {
|
||||
'continue-reading': 'Continue Reading',
|
||||
'up-next-in-series': 'Up Next in Series',
|
||||
'recently-added': 'Recently Added',
|
||||
random: 'Discover Something New',
|
||||
'smart-scope': 'Smart Scope',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const SCROLLER_TYPE = {
|
||||
RECENTLY_ADDED: "recently-added",
|
||||
CONTINUE_READING: "continue-reading",
|
||||
UP_NEXT_IN_SERIES: "up-next-in-series",
|
||||
RANDOM: "random",
|
||||
SMART_SCOPE: "smart-scope",
|
||||
} as const;
|
||||
|
||||
@@ -50,6 +50,20 @@ describe('DashboardController', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('scroller endpoint', () => {
|
||||
it('getScroller delegates to dashboardService with the provided scroller type and params', async () => {
|
||||
const { controller, dashboardService } = makeController();
|
||||
const user = makeUser({ id: 7 });
|
||||
const mockResult = [{ id: 101 }];
|
||||
dashboardService.getScroller.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await controller.getScroller('up-next-in-series', 33, 0, user);
|
||||
|
||||
expect(dashboardService.getScroller).toHaveBeenCalledWith('up-next-in-series', user, 33, 0);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('widget endpoints', () => {
|
||||
it('getReadingGoal delegates to widgetService', async () => {
|
||||
const { controller, widgetService } = makeController();
|
||||
|
||||
@@ -26,13 +26,15 @@ describe('DashboardRepository', () => {
|
||||
});
|
||||
|
||||
it('returns empty results without querying when no libraries are accessible', async () => {
|
||||
const db = { select: vi.fn() };
|
||||
const db = { select: vi.fn(), execute: vi.fn() };
|
||||
const repo = new DashboardRepository(db as never);
|
||||
|
||||
await expect(repo.findRecentlyAddedBookIds([], 20)).resolves.toEqual([]);
|
||||
await expect(repo.findContinueReadingBookIds([], 1, 20)).resolves.toEqual([]);
|
||||
await expect(repo.findUpNextInSeriesBookIds([], 1, 20)).resolves.toEqual([]);
|
||||
await expect(repo.findRandomBookIds([], 1, 20)).resolves.toEqual([]);
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
expect(db.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps recently added rows to id list', async () => {
|
||||
@@ -96,4 +98,43 @@ describe('DashboardRepository', () => {
|
||||
expect(result).toEqual([]);
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps up-next-in-series rows to id list', async () => {
|
||||
const db = {
|
||||
select: vi.fn(),
|
||||
execute: vi.fn().mockResolvedValue({ rows: [{ id: 17 }, { id: 4 }] }),
|
||||
};
|
||||
const repo = new DashboardRepository(db as never);
|
||||
|
||||
const result = await repo.findUpNextInSeriesBookIds([9], 55, 10);
|
||||
|
||||
expect(result).toEqual([17, 4]);
|
||||
expect(db.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns empty up-next-in-series ids and does not query when limit is zero', async () => {
|
||||
const db = {
|
||||
select: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
};
|
||||
const repo = new DashboardRepository(db as never);
|
||||
|
||||
const result = await repo.findUpNextInSeriesBookIds([9], 55, 0);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(db.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns empty up-next-in-series ids when query returns no rows', async () => {
|
||||
const db = {
|
||||
select: vi.fn(),
|
||||
execute: vi.fn().mockResolvedValue({ rows: [] }),
|
||||
};
|
||||
const repo = new DashboardRepository(db as never);
|
||||
|
||||
const result = await repo.findUpNextInSeriesBookIds([2], 101, 20);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(db.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,11 @@ import { ContentFilterRules } from '@bookorbit/types';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { audiobookProgress, bookFiles, books, readingProgress } from '../../db/schema';
|
||||
import { audiobookProgress, bookFiles, bookMetadata, books, readingProgress, userBookStatus } from '../../db/schema';
|
||||
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
type UpNextInSeriesRow = { id: number };
|
||||
|
||||
@Injectable()
|
||||
export class DashboardRepository {
|
||||
@@ -70,6 +71,117 @@ export class DashboardRepository {
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async findUpNextInSeriesBookIds(
|
||||
accessibleLibraryIds: number[],
|
||||
userId: number,
|
||||
limit: number,
|
||||
contentFilters?: ContentFilterRules,
|
||||
): Promise<number[]> {
|
||||
if (accessibleLibraryIds.length === 0) return [];
|
||||
if (limit <= 0) return [];
|
||||
|
||||
const mergedProgress = sql<number>`
|
||||
coalesce(
|
||||
case
|
||||
when ${readingProgress.updatedAt} is null then ${audiobookProgress.percentage}
|
||||
when ${audiobookProgress.updatedAt} is null then ${readingProgress.percentage}
|
||||
when ${readingProgress.updatedAt} >= ${audiobookProgress.updatedAt} then ${readingProgress.percentage}
|
||||
else ${audiobookProgress.percentage}
|
||||
end,
|
||||
${readingProgress.percentage},
|
||||
${audiobookProgress.percentage},
|
||||
0
|
||||
)
|
||||
`;
|
||||
const mergedUpdatedAt = sql<Date | null>`
|
||||
case
|
||||
when ${readingProgress.updatedAt} is null then ${audiobookProgress.updatedAt}
|
||||
when ${audiobookProgress.updatedAt} is null then ${readingProgress.updatedAt}
|
||||
when ${readingProgress.updatedAt} >= ${audiobookProgress.updatedAt} then ${readingProgress.updatedAt}
|
||||
else ${audiobookProgress.updatedAt}
|
||||
end
|
||||
`;
|
||||
const completionPredicate = sql<boolean>`${userBookStatus.status} in ('read', 'skimmed') or ${mergedProgress} >= 100`;
|
||||
const cfClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
|
||||
const libraryIdList = sql.join(
|
||||
accessibleLibraryIds.map((libraryId) => sql`${libraryId}`),
|
||||
sql`, `,
|
||||
);
|
||||
const filterSql = cfClauses.length > 0 ? sql`and ${sql.join(cfClauses, sql` and `)}` : sql``;
|
||||
|
||||
const rows = await this.db.execute<UpNextInSeriesRow>(sql`
|
||||
with scoped_series_books as (
|
||||
select
|
||||
${books.id} as id,
|
||||
${books.libraryId} as library_id,
|
||||
lower(btrim(${bookMetadata.seriesName})) as normalized_series_name,
|
||||
${bookMetadata.seriesIndex} as series_index,
|
||||
${books.addedAt} as added_at,
|
||||
${mergedProgress} as current_progress,
|
||||
case
|
||||
when ${completionPredicate} then true
|
||||
else false
|
||||
end as is_completed,
|
||||
case
|
||||
when ${completionPredicate}
|
||||
then greatest(
|
||||
coalesce(${userBookStatus.updatedAt}, to_timestamp(0)),
|
||||
coalesce(${mergedUpdatedAt}, to_timestamp(0))
|
||||
)
|
||||
else null
|
||||
end as completion_updated_at
|
||||
from ${books}
|
||||
inner join ${bookMetadata} on ${bookMetadata.bookId} = ${books.id}
|
||||
left join ${bookFiles} on ${bookFiles.id} = ${books.primaryFileId}
|
||||
left join ${readingProgress} on ${readingProgress.bookFileId} = ${bookFiles.id} and ${readingProgress.userId} = ${userId}
|
||||
left join ${audiobookProgress} on ${audiobookProgress.bookId} = ${books.id} and ${audiobookProgress.userId} = ${userId}
|
||||
left join ${userBookStatus} on ${userBookStatus.bookId} = ${books.id} and ${userBookStatus.userId} = ${userId}
|
||||
where ${books.libraryId} in (${libraryIdList})
|
||||
and ${books.status} = 'present'
|
||||
and ${bookMetadata.seriesName} is not null
|
||||
and btrim(${bookMetadata.seriesName}) != ''
|
||||
and ${bookMetadata.seriesIndex} is not null
|
||||
${filterSql}
|
||||
),
|
||||
ordered_series as (
|
||||
select
|
||||
ssb.id,
|
||||
ssb.library_id,
|
||||
ssb.normalized_series_name,
|
||||
ssb.series_index,
|
||||
ssb.added_at,
|
||||
ssb.current_progress,
|
||||
ssb.is_completed,
|
||||
ssb.completion_updated_at,
|
||||
lag(ssb.is_completed) over (
|
||||
partition by ssb.library_id, ssb.normalized_series_name
|
||||
order by ssb.series_index asc, ssb.added_at asc, ssb.id asc
|
||||
) as previous_is_completed,
|
||||
lag(ssb.completion_updated_at) over (
|
||||
partition by ssb.library_id, ssb.normalized_series_name
|
||||
order by ssb.series_index asc, ssb.added_at asc, ssb.id asc
|
||||
) as previous_completion_updated_at
|
||||
from scoped_series_books ssb
|
||||
),
|
||||
next_candidates as (
|
||||
select distinct on (os.library_id, os.normalized_series_name)
|
||||
os.id,
|
||||
os.previous_completion_updated_at
|
||||
from ordered_series os
|
||||
where os.previous_is_completed = true
|
||||
and os.is_completed = false
|
||||
and os.current_progress = 0
|
||||
order by os.library_id, os.normalized_series_name, os.series_index asc, os.added_at asc, os.id asc
|
||||
)
|
||||
select nc.id
|
||||
from next_candidates nc
|
||||
order by nc.previous_completion_updated_at desc nulls last, nc.id desc
|
||||
limit ${limit}
|
||||
`);
|
||||
|
||||
return rows.rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async findRandomBookIds(accessibleLibraryIds: number[], userId: number, limit: number, contentFilters?: ContentFilterRules): Promise<number[]> {
|
||||
if (accessibleLibraryIds.length === 0) return [];
|
||||
if (limit <= 0) return [];
|
||||
|
||||
@@ -29,6 +29,7 @@ function makeService() {
|
||||
const dashboardRepo = {
|
||||
findRecentlyAddedBookIds: vi.fn(),
|
||||
findContinueReadingBookIds: vi.fn(),
|
||||
findUpNextInSeriesBookIds: vi.fn(),
|
||||
findRandomBookIds: vi.fn(),
|
||||
};
|
||||
const bookReadService = {
|
||||
@@ -149,6 +150,43 @@ describe('DashboardService', () => {
|
||||
expect(result.map((card) => card.id)).toEqual([4]);
|
||||
});
|
||||
|
||||
it('routes up-next-in-series requests to repository and preserves response order', async () => {
|
||||
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
|
||||
const user = makeUser({ id: 11 });
|
||||
libraryService.findAccessibleLibraryIds.mockResolvedValue([707]);
|
||||
dashboardRepo.findUpNextInSeriesBookIds.mockResolvedValue([19, 8]);
|
||||
bookReadService.findCardsByBookIds.mockResolvedValue(makeFindCardsResult([8, 19]));
|
||||
|
||||
const result = await service.getScroller(ScrollerType.UP_NEXT_IN_SERIES, user, 25);
|
||||
|
||||
expect(dashboardRepo.findUpNextInSeriesBookIds).toHaveBeenCalledWith([707], 11, 25, EMPTY_CONTENT_FILTER_RULES);
|
||||
expect(result.map((card) => card.id)).toEqual([19, 8]);
|
||||
});
|
||||
|
||||
it('passes undefined content filters for up-next-in-series when user is superuser', async () => {
|
||||
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
|
||||
const superuser = makeUser({ id: 17, isSuperuser: true, contentFilters: EMPTY_CONTENT_FILTER_RULES });
|
||||
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
|
||||
dashboardRepo.findUpNextInSeriesBookIds.mockResolvedValue([55]);
|
||||
bookReadService.findCardsByBookIds.mockResolvedValue(makeFindCardsResult([55]));
|
||||
|
||||
await service.getScroller(ScrollerType.UP_NEXT_IN_SERIES, superuser, 20);
|
||||
|
||||
expect(dashboardRepo.findUpNextInSeriesBookIds).toHaveBeenCalledWith([1], 17, 20, undefined);
|
||||
});
|
||||
|
||||
it('clamps up-next-in-series limit to minimum of 1', async () => {
|
||||
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
|
||||
const user = makeUser({ id: 18 });
|
||||
libraryService.findAccessibleLibraryIds.mockResolvedValue([91]);
|
||||
dashboardRepo.findUpNextInSeriesBookIds.mockResolvedValue([3]);
|
||||
bookReadService.findCardsByBookIds.mockResolvedValue(makeFindCardsResult([3]));
|
||||
|
||||
await service.getScroller(ScrollerType.UP_NEXT_IN_SERIES, user, 0);
|
||||
|
||||
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 () => {
|
||||
const { service, dashboardRepo, bookReadService, libraryService } = makeService();
|
||||
libraryService.findAccessibleLibraryIds.mockResolvedValue([901]);
|
||||
|
||||
@@ -54,6 +54,9 @@ export class DashboardService {
|
||||
case ScrollerType.CONTINUE_READING:
|
||||
bookIds = await this.dashboardRepo.findContinueReadingBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
||||
break;
|
||||
case ScrollerType.UP_NEXT_IN_SERIES:
|
||||
bookIds = await this.dashboardRepo.findUpNextInSeriesBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
||||
break;
|
||||
case ScrollerType.RANDOM:
|
||||
bookIds = await this.dashboardRepo.findRandomBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user