fix(books): reshuffle random sort per visit instead of per day

Seed the random sort field from a per-view randomSeed on BookQuery rather
than a day counter, so a reload or a scope change draws a new order while
every page of one listing stays coherent. Callers that omit the seed keep
the daily fallback. Adds a shuffle button to the library, smart scope, and
collection toolbars when the active sort is random.

Closes #1153
This commit is contained in:
neon
2026-08-26 15:25:46 +00:00
parent d587d8f2a8
commit 4c42e0c73f
17 changed files with 297 additions and 26 deletions
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Shuffle } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const props = defineProps<{
/** Icon only, for the dense desktop toolbar. */
compact?: boolean
/** Hide below the `sm` breakpoint, where the mobile control sheet carries its own copy. */
desktopOnly?: boolean
}>()
const emit = defineEmits<{ shuffle: [] }>()
const { t } = useI18n()
const visibility = computed(() => (props.desktopOnly ? 'hidden sm:flex' : 'flex'))
function handleShuffle() {
emit('shuffle')
}
</script>
<template>
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
:aria-label="t('views.bookView.shuffleAria')"
class="h-8 items-center justify-center gap-1.5 rounded-md border border-input bg-background text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:text-foreground"
:class="[visibility, compact ? 'w-8' : 'px-2.5 text-sm']"
@click="handleShuffle"
>
<Shuffle :size="13" />
<span v-if="!compact">{{ t('views.bookView.shuffle') }}</span>
</button>
</TooltipTrigger>
<TooltipContent>{{ t('views.bookView.shuffleHint') }}</TooltipContent>
</Tooltip>
</template>
@@ -1,4 +1,5 @@
import { nextTick, ref } from 'vue' import { defineComponent, nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BookCard, BooksPage, GroupRule, JumpBucket, SortSpec } from '@bookorbit/types' import type { BookCard, BooksPage, GroupRule, JumpBucket, SortSpec } from '@bookorbit/types'
import { COLLECTION_DEFAULT_SORT } from '../../lib/sort-defaults' import { COLLECTION_DEFAULT_SORT } from '../../lib/sort-defaults'
@@ -403,4 +404,100 @@ describe('useBookViewWindow', () => {
win.releaseRailGutter() win.releaseRailGutter()
}) })
it('omits the shuffle seed until a sort tier is random', async () => {
mockApi(120)
const { win } = setup()
await flush()
expect(win.randomSortActive.value).toBe(false)
expect(listRequestBodies()[0]).not.toHaveProperty('randomSeed')
win.sort.value = [{ field: 'random', dir: 'asc' }]
await flush()
expect(win.randomSortActive.value).toBe(true)
const seed = listRequestBodies().at(-1)?.randomSeed
expect(typeof seed).toBe('number')
})
it('sends one seed for every page of a shuffled listing', async () => {
mockApi(250)
const { win } = setup({ defaultSort: [{ field: 'random', dir: 'asc' }] })
await flush()
await win.ensureRange(100, 199)
await flush()
const seeds = listRequestBodies().map((body) => body.randomSeed)
expect(seeds.length).toBeGreaterThan(1)
expect(new Set(seeds).size).toBe(1)
expect(typeof seeds[0]).toBe('number')
})
it('reshuffles into a new seed and refetches', async () => {
mockApi(120)
const { win } = setup({ defaultSort: [{ field: 'random', dir: 'asc' }] })
await flush()
const first = listRequestBodies().at(-1)?.randomSeed
win.reshuffle()
await flush()
const second = listRequestBodies().at(-1)?.randomSeed
expect(second).not.toBe(first)
expect(listRequestBodies().length).toBeGreaterThan(1)
})
it('reshuffles when the scope changes so two scopes never share an order', async () => {
mockApi(120)
const { win, scopeId } = setup({ defaultSort: [{ field: 'random', dir: 'asc' }] })
await flush()
const first = listRequestBodies().at(-1)?.randomSeed
scopeId.value = 2
await flush()
expect(listRequestBodies().at(-1)?.randomSeed).not.toBe(first)
expect(win.randomSortActive.value).toBe(true)
})
it('reshuffles when a kept-alive random listing is revisited', async () => {
mockApi(120)
const visible = ref(true)
const Source = defineComponent({
setup() {
useBookViewWindow({
scopeId: ref(1),
listEndpoint: (id) => `/api/v1/libraries/${id}/books`,
bucketsEndpoint: (id) => `/api/v1/libraries/${id}/books/jump-buckets`,
viewMode: ref('table'),
defaultSort: [{ field: 'random', dir: 'asc' }],
})
return {}
},
template: '<div />',
})
const Harness = defineComponent({
components: { Source },
setup() {
return { visible }
},
template: '<KeepAlive><Source v-if="visible" /></KeepAlive>',
})
const wrapper = mount(Harness)
await flush()
const first = listRequestBodies().at(-1)?.randomSeed
fetchMock.mockClear()
visible.value = false
await nextTick()
visible.value = true
await flush()
expect(listRequestBodies().at(-1)?.randomSeed).not.toBe(first)
wrapper.unmount()
})
}) })
@@ -1,6 +1,14 @@
import { computed, ref, type Ref } from 'vue' import { computed, getCurrentInstance, onActivated, ref, watch, type Ref } from 'vue'
import { useElementSize, useWindowSize } from '@vueuse/core' import { useElementSize, useWindowSize } from '@vueuse/core'
import { jumpBucketKindForSort, type GroupRule, type JumpBucket, type SortSpec } from '@bookorbit/types' import {
MAX_RANDOM_SORT_SEED,
createRandomSortSeed,
hasRandomSort,
jumpBucketKindForSort,
type GroupRule,
type JumpBucket,
type SortSpec,
} from '@bookorbit/types'
import { useBookProgressRefresh } from './useBookProgressRefresh' import { useBookProgressRefresh } from './useBookProgressRefresh'
import { BOOK_WINDOW_BLOCK_SIZE, useBookWindow, type BookWindowQuery } from './useBookWindow' import { BOOK_WINDOW_BLOCK_SIZE, useBookWindow, type BookWindowQuery } from './useBookWindow'
import { useJumpBuckets } from './useJumpBuckets' import { useJumpBuckets } from './useJumpBuckets'
@@ -39,8 +47,36 @@ export function useBookViewWindow(options: {
const filter = ref<GroupRule | undefined>(undefined) const filter = ref<GroupRule | undefined>(undefined)
const sort = ref<SortSpec[]>(copySort(options.defaultSort ?? DEFAULT_SORT)) const sort = ref<SortSpec[]>(copySort(options.defaultSort ?? DEFAULT_SORT))
// One shuffle per visit: the seed is minted here and travels with every page request, so
// paging stays coherent while a reload, a different scope, or the shuffle button re-rolls it.
const randomSeed = ref(createRandomSortSeed())
const randomSortActive = computed(() => hasRandomSort(sort.value))
function reshuffle() {
const next = createRandomSortSeed()
randomSeed.value = next === randomSeed.value ? (next + 1) % (MAX_RANDOM_SORT_SEED + 1) : next
}
// Registered before useBookWindow so a sort or scope change re-rolls the seed in the same flush
// the window reads the query, rather than costing a second round trip.
watch(randomSortActive, (active) => {
if (active) reshuffle()
})
watch(options.scopeId, () => {
reshuffle()
})
if (getCurrentInstance()) {
let hasActivated = false
onActivated(() => {
if (hasActivated && randomSortActive.value) reshuffle()
hasActivated = true
})
}
const query = computed<BookWindowQuery>(() => ({ const query = computed<BookWindowQuery>(() => ({
sort: sort.value, sort: sort.value,
...(randomSortActive.value ? { randomSeed: randomSeed.value } : {}),
...(filter.value ? { filter: filter.value } : {}), ...(filter.value ? { filter: filter.value } : {}),
...(options.collapseEnabled?.value ? { collapseSeries: true } : {}), ...(options.collapseEnabled?.value ? { collapseSeries: true } : {}),
...(options.q?.value.trim() ? { q: options.q.value.trim() } : {}), ...(options.q?.value.trim() ? { q: options.q.value.trim() } : {}),
@@ -130,6 +166,8 @@ export function useBookViewWindow(options: {
filter, filter,
sort, sort,
query, query,
randomSortActive,
reshuffle,
firstVisibleIndex, firstVisibleIndex,
handleFirstVisibleIndex, handleFirstVisibleIndex,
handleRange, handleRange,
+3
View File
@@ -7989,6 +7989,9 @@
"sort": "Sort", "sort": "Sort",
"sortOn": "On", "sortOn": "On",
"resetSort": "Reset sort to default", "resetSort": "Reset sort to default",
"shuffle": "Shuffle",
"shuffleAria": "Shuffle the book order",
"shuffleHint": "Draw a new random order",
"filters": "Filters", "filters": "Filters",
"clear": "Clear", "clear": "Clear",
"clearAllFilters": "Clear all filters", "clearAllFilters": "Clear all filters",
+5
View File
@@ -19,6 +19,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import BookSortBuilder from '@/features/book/components/BookSortBuilder.vue' import BookSortBuilder from '@/features/book/components/BookSortBuilder.vue'
import BookShuffleButton from '@/features/book/components/BookShuffleButton.vue'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu' import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue' import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
import BookListRow from '@/features/book/components/BookListRow.vue' import BookListRow from '@/features/book/components/BookListRow.vue'
@@ -105,6 +106,8 @@ const {
initialized: booksInitialized, initialized: booksInitialized,
error: booksError, error: booksError,
sort: tableSort, sort: tableSort,
randomSortActive,
reshuffle,
reset: resetBooks, reset: resetBooks,
contiguousPrefix, contiguousPrefix,
hasMorePrefix, hasMorePrefix,
@@ -544,6 +547,7 @@ defineOptions({ name: 'CollectionView' })
<TooltipContent>{{ t('views.bookView.resetSort') }}</TooltipContent> <TooltipContent>{{ t('views.bookView.resetSort') }}</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<BookShuffleButton v-if="randomSortActive" desktop-only compact @shuffle="reshuffle" />
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
@@ -680,6 +684,7 @@ defineOptions({ name: 'CollectionView' })
> >
<X :size="13" /> <X :size="13" />
</button> </button>
<BookShuffleButton v-if="randomSortActive" @shuffle="reshuffle" />
<button <button
v-if="hasPermission('library_download') && !isDemoRestrictedAccount" v-if="hasPermission('library_download') && !isDemoRestrictedAccount"
class="flex h-8 items-center gap-1.5 rounded-md border border-input px-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" class="flex h-8 items-center gap-1.5 rounded-md border border-input px-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
+6
View File
@@ -25,6 +25,7 @@ import TableColumnPanel from '@/features/book/components/TableColumnPanel.vue'
import BookQuickView from '@/features/book/components/BookQuickView.vue' import BookQuickView from '@/features/book/components/BookQuickView.vue'
import BookFilterBuilder from '@/features/book/components/BookFilterBuilder.vue' import BookFilterBuilder from '@/features/book/components/BookFilterBuilder.vue'
import BookSortBuilder from '@/features/book/components/BookSortBuilder.vue' import BookSortBuilder from '@/features/book/components/BookSortBuilder.vue'
import BookShuffleButton from '@/features/book/components/BookShuffleButton.vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu' import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import ViewHeader from '@/components/ViewHeader.vue' import ViewHeader from '@/components/ViewHeader.vue'
@@ -119,6 +120,8 @@ const {
error, error,
filter, filter,
sort, sort,
randomSortActive,
reshuffle,
reset: resetBooks, reset: resetBooks,
updateBooks, updateBooks,
contiguousPrefix, contiguousPrefix,
@@ -645,6 +648,7 @@ defineOptions({ name: 'HomeView' })
<TooltipContent>{{ t('views.bookView.resetSort') }}</TooltipContent> <TooltipContent>{{ t('views.bookView.resetSort') }}</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<BookShuffleButton v-if="randomSortActive" desktop-only compact @shuffle="reshuffle" />
<div class="hidden sm:block w-px h-5 bg-border shrink-0" /> <div class="hidden sm:block w-px h-5 bg-border shrink-0" />
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
@@ -804,6 +808,8 @@ defineOptions({ name: 'HomeView' })
<X :size="13" /> <X :size="13" />
</button> </button>
<BookShuffleButton v-if="randomSortActive" @shuffle="reshuffle" />
<button <button
@click="toggleFilterPanel" @click="toggleFilterPanel"
class="flex items-center gap-1.5 h-8 px-2.5 rounded-md border text-sm transition-colors" class="flex items-center gap-1.5 h-8 px-2.5 rounded-md border text-sm transition-colors"
+5
View File
@@ -51,6 +51,7 @@ import { useViewDisplaySettings } from '@/composables/useViewDisplaySettings'
import { useViewSearch } from '@/features/book/composables/useViewSearch' import { useViewSearch } from '@/features/book/composables/useViewSearch'
import FilterSummary from '@/features/book/components/FilterSummary.vue' import FilterSummary from '@/features/book/components/FilterSummary.vue'
import { sortFieldLabel } from '@/features/book/lib/filter-labels' import { sortFieldLabel } from '@/features/book/lib/filter-labels'
import BookShuffleButton from '@/features/book/components/BookShuffleButton.vue'
import { DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio' import { DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
import { usePageTitle } from '@/composables/usePageTitle' import { usePageTitle } from '@/composables/usePageTitle'
import { useBookNavigation } from '@/features/book/composables/useBookNavigation' import { useBookNavigation } from '@/features/book/composables/useBookNavigation'
@@ -101,6 +102,8 @@ const {
initialized: booksInitialized, initialized: booksInitialized,
error: booksError, error: booksError,
sort: tableSort, sort: tableSort,
randomSortActive,
reshuffle,
reset: resetBooks, reset: resetBooks,
contiguousPrefix, contiguousPrefix,
hasMorePrefix, hasMorePrefix,
@@ -573,6 +576,7 @@ defineOptions({ name: 'SmartScopeView' })
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{{ filterExpanded ? 'Hide filter summary' : 'Show filter summary' }}</TooltipContent> <TooltipContent>{{ filterExpanded ? 'Hide filter summary' : 'Show filter summary' }}</TooltipContent>
</Tooltip> </Tooltip>
<BookShuffleButton v-if="randomSortActive" desktop-only compact @shuffle="reshuffle" />
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button <button
@@ -727,6 +731,7 @@ defineOptions({ name: 'SmartScopeView' })
<component :is="filterExpanded ? ChevronUp : ChevronDown" :size="13" /> <component :is="filterExpanded ? ChevronUp : ChevronDown" :size="13" />
<span>{{ filterExpanded ? 'Hide filter' : 'Show filter' }}</span> <span>{{ filterExpanded ? 'Hide filter' : 'Show filter' }}</span>
</button> </button>
<BookShuffleButton v-if="randomSortActive" @shuffle="reshuffle" />
<button <button
v-if="hasPermission('library_download') && !isDemoRestrictedAccount" v-if="hasPermission('library_download') && !isDemoRestrictedAccount"
@click="openMetadataExport" @click="openMetadataExport"
+22 -1
View File
@@ -235,7 +235,8 @@ export type GroupRule = {
* - `rating` - from `user_book_ratings.rating` (per-user, correlated subquery) * - `rating` - from `user_book_ratings.rating` (per-user, correlated subquery)
* - `format` - from `book_files.format` for the primary file (correlated subquery) * - `format` - from `book_files.format` for the primary file (correlated subquery)
* - `publishedDate` - uses full dates when available and falls back to published year * - `publishedDate` - uses full dates when available and falls back to published year
* - `random` - day-seeded pseudorandom based on book id and user id * - `random` - pseudorandom, seeded by `BookQuery.randomSeed` so one browsing session
* keeps a stable order across pages while a new visit reshuffles
* *
* Fields marked "per-user, correlated subquery" require an authenticated userId and * Fields marked "per-user, correlated subquery" require an authenticated userId and
* execute a subquery per result row; they are slower on large result sets. * execute a subquery per result row; they are slower on large result sets.
@@ -383,4 +384,24 @@ export type BookQuery = {
pagination: { page: number; size: number }; pagination: { page: number; size: number };
collapseSeries?: boolean; collapseSeries?: boolean;
q?: string; q?: string;
/**
* Shuffle seed for the `random` sort field. Every page of one listing must send the same
* value or paging would draw from a different shuffle and repeat or skip books; a new value
* reshuffles. Ignored when no sort tier is `random`. Callers that omit it get a per-user
* order that only changes daily.
*/
randomSeed?: number;
}; };
/** Upper bound for `BookQuery.randomSeed`, chosen so the seed always fits a signed 32-bit int. */
export const MAX_RANDOM_SORT_SEED = 2_147_483_647;
/** True when any sort tier shuffles, which is what makes `BookQuery.randomSeed` meaningful. */
export function hasRandomSort(sort: SortSpec[] | undefined): boolean {
return sort?.some((spec) => spec.field === "random") ?? false;
}
/** A fresh shuffle seed. One per browsing session, reused for every page of that session. */
export function createRandomSortSeed(): number {
return Math.floor(Math.random() * (MAX_RANDOM_SORT_SEED + 1));
}
@@ -1846,6 +1846,12 @@ describe('BookQueryBuilder.buildCollapseOrderBy', () => {
} }
}); });
it('uses the supplied shuffle seed for the collapsed random sort', () => {
const result = BookQueryBuilder.buildCollapseOrderBy([{ field: 'random', dir: 'asc' }], 7, undefined, { randomSeed: 4242 });
expect(result).toContain(`md5(r.id::text || ':' || 4242::text) ASC`);
});
it('joins multiple sort parts with comma', () => { it('joins multiple sort parts with comma', () => {
const result = BookQueryBuilder.buildCollapseOrderBy( const result = BookQueryBuilder.buildCollapseOrderBy(
[ [
@@ -19,7 +19,7 @@ import { buildContentFilterClauses } from '../../common/utils/content-filter-sql
import { accentInsensitiveIlike, buildSearchPattern, escapeLikePattern } from '../../common/utils/accent-insensitive-search.utils'; import { accentInsensitiveIlike, buildSearchPattern, escapeLikePattern } from '../../common/utils/accent-insensitive-search.utils';
import { compareSeriesIndexSql, seriesIndexSortKey, seriesIndexSortKeySql } from '../../common/utils/series-index-sql.utils'; import { compareSeriesIndexSql, seriesIndexSortKey, seriesIndexSortKeySql } from '../../common/utils/series-index-sql.utils';
import * as schema from '../../db/schema'; import * as schema from '../../db/schema';
import { BookSortBuilder, customMetadataValueColumn, type BookSortContext } from './book-sort-builder.service'; import { BookSortBuilder, customMetadataValueColumn, resolveRandomSortSeed, type BookSortContext } from './book-sort-builder.service';
import { import {
audiobookProgress, audiobookProgress,
authors, authors,
@@ -1057,7 +1057,7 @@ export class BookQueryBuilder {
return node.rules.some((r) => BookQueryBuilder.hasSeriesSelectionFilter(r)); return node.rules.some((r) => BookQueryBuilder.hasSeriesSelectionFilter(r));
} }
static buildCollapseOrderBy(sort: SortSpec[], userId: number, customFieldTypes?: CustomMetadataFieldTypeMap): string { static buildCollapseOrderBy(sort: SortSpec[], userId: number, customFieldTypes?: CustomMetadataFieldTypeMap, context?: BookSortContext): string {
if (sort.length === 0) return 'sort_title ASC NULLS LAST, r.id ASC'; if (sort.length === 0) return 'sort_title ASC NULLS LAST, r.id ASC';
if (!Number.isSafeInteger(userId)) throw new BadRequestException('Invalid userId for collapse order'); if (!Number.isSafeInteger(userId)) throw new BadRequestException('Invalid userId for collapse order');
@@ -1143,9 +1143,8 @@ export class BookQueryBuilder {
parts.push(`(SELECT ubs.started_at FROM user_book_status ubs WHERE ubs.book_id = r.id AND ubs.user_id = ${safeUserId}) ${D} NULLS LAST`); parts.push(`(SELECT ubs.started_at FROM user_book_status ubs WHERE ubs.book_id = r.id AND ubs.user_id = ${safeUserId}) ${D} NULLS LAST`);
break; break;
case 'random': { case 'random': {
const daySeed = Math.floor(Date.now() / 86_400_000); const seed = Math.trunc(resolveRandomSortSeed(context, userId));
const scopedSeed = daySeed + userId; parts.push(`md5(r.id::text || ':' || ${seed}::text) ${D}`);
parts.push(`md5(r.id::text || ':' || ${scopedSeed}::text) ${D}`);
parts.push(`r.id ${D}`); parts.push(`r.id ${D}`);
break; break;
} }
@@ -137,6 +137,24 @@ describe('BookSortBuilder', () => {
expect(raw).toHaveBeenNthCalledWith(2, 'DESC'); expect(raw).toHaveBeenNthCalledWith(2, 'DESC');
}); });
it('uses the supplied shuffle seed instead of the daily fallback', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-15T00:00:00Z'));
const result = service.build([{ field: 'random', dir: 'asc' }], 7, undefined, { randomSeed: 12345 });
expect(result[0]?.values[1]).toBe(12345);
});
it('falls back to the daily seed when the supplied shuffle seed is out of range', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-15T00:00:00Z'));
const result = service.build([{ field: 'random', dir: 'asc' }], 7, undefined, { randomSeed: -1 });
expect(result[0]?.values[1]).toBe(Math.floor(new Date('2025-01-15T00:00:00Z').getTime() / 86_400_000) + 7);
});
it('throws for readProgress sort without userId', () => { it('throws for readProgress sort without userId', () => {
expect(() => service.build([{ field: 'readProgress', dir: 'asc' }])).toThrow( expect(() => service.build([{ field: 'readProgress', dir: 'asc' }])).toThrow(
new BadRequestException('readProgress sort requires an authenticated user'), new BadRequestException('readProgress sort requires an authenticated user'),
@@ -1,15 +1,27 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { AnyColumn, SQL, sql } from 'drizzle-orm'; import { AnyColumn, SQL, sql } from 'drizzle-orm';
import { parseCustomSortFieldId } from '@bookorbit/types'; import { MAX_RANDOM_SORT_SEED, parseCustomSortFieldId } from '@bookorbit/types';
import type { CustomMetadataFieldType, CustomMetadataFieldTypeMap, SortField, SortSpec } from '@bookorbit/types'; import type { CustomMetadataFieldType, CustomMetadataFieldTypeMap, SortField, SortSpec } from '@bookorbit/types';
import { bookMetadata, books, collectionBooks } from '../../db/schema'; import { bookMetadata, books, collectionBooks } from '../../db/schema';
import { seriesIndexOrderBy } from '../../common/utils/series-index-sql.utils'; import { seriesIndexOrderBy } from '../../common/utils/series-index-sql.utils';
export type BookSortContext = { export type BookSortContext = {
defaultCollectionId?: number; defaultCollectionId?: number;
/** Seed for the `random` sort field. See `resolveRandomSortSeed`. */
randomSeed?: number;
}; };
/**
* Browsing clients send a seed so every page uses the same shuffle and a fresh visit reshuffles.
* Other callers fall back to a per-user daily seed to keep pagination coherent within a day.
*/
export function resolveRandomSortSeed(context: BookSortContext | undefined, userId: number | undefined): number {
const seed = context?.randomSeed;
if (seed !== undefined && Number.isSafeInteger(seed) && seed >= 0 && seed <= MAX_RANDOM_SORT_SEED) return seed;
return Math.floor(Date.now() / 86_400_000) + (userId ?? 0);
}
const CUSTOM_VALUE_COLUMNS: Record<CustomMetadataFieldType, string> = { const CUSTOM_VALUE_COLUMNS: Record<CustomMetadataFieldType, string> = {
text: 'value_text', text: 'value_text',
url: 'value_text', url: 'value_text',
@@ -125,9 +137,8 @@ export class BookSortBuilder {
break; break;
} }
case 'random': { case 'random': {
const daySeed = Math.floor(Date.now() / 86_400_000); const seed = resolveRandomSortSeed(context, userId);
const scopedSeed = daySeed + (userId ?? 0); result.push(sql`md5(${books.id}::text || ':' || ${seed}::text) ${sql.raw(D)}`);
result.push(sql`md5(${books.id}::text || ':' || ${scopedSeed}::text) ${sql.raw(D)}`);
result.push(sql`${books.id} ${sql.raw(D)}`); result.push(sql`${books.id} ${sql.raw(D)}`);
break; break;
} }
+4 -2
View File
@@ -760,6 +760,7 @@ export class BookRepository {
userId: number; userId: number;
customFieldTypes?: CustomMetadataFieldTypeMap; customFieldTypes?: CustomMetadataFieldTypeMap;
defaultCollectionId?: number; defaultCollectionId?: number;
randomSeed?: number;
}): Promise<{ }): Promise<{
rows: Array<{ rows: Array<{
id: number; id: number;
@@ -824,7 +825,7 @@ export class BookRepository {
throw new BadRequestException('Invalid default collection id'); throw new BadRequestException('Invalid default collection id');
} }
const whereFragment = this.visibleWhere(where); const whereFragment = this.visibleWhere(where);
const orderBy = BookQueryBuilder.buildCollapseOrderBy(sort, userId, opts.customFieldTypes); const orderBy = BookQueryBuilder.buildCollapseOrderBy(sort, userId, opts.customFieldTypes, { randomSeed: opts.randomSeed });
// The collectionOrder branch of the order by names sort_collection_position, so the column has // The collectionOrder branch of the order by names sort_collection_position, so the column has
// to exist even for the library and smart scope queries that can never sort on it. // to exist even for the library and smart scope queries that can never sort on it.
const collectionPosition = const collectionPosition =
@@ -1167,11 +1168,12 @@ export class BookRepository {
userId: number; userId: number;
maxBuckets: number; maxBuckets: number;
customFieldTypes?: CustomMetadataFieldTypeMap; customFieldTypes?: CustomMetadataFieldTypeMap;
randomSeed?: number;
}): Promise<JumpBucketsResponse> { }): Promise<JumpBucketsResponse> {
const source = collapsedDiscreteSourceParts(opts.field, opts.userId); const source = collapsedDiscreteSourceParts(opts.field, opts.userId);
if (!source) return { buckets: [], total: 0, kind: opts.kind, granularity: null }; if (!source) return { buckets: [], total: 0, kind: opts.kind, granularity: null };
const whereFragment = this.visibleWhere(opts.where); const whereFragment = this.visibleWhere(opts.where);
const orderBy = BookQueryBuilder.buildCollapseOrderBy(opts.sort, opts.userId, opts.customFieldTypes); const orderBy = BookQueryBuilder.buildCollapseOrderBy(opts.sort, opts.userId, opts.customFieldTypes, { randomSeed: opts.randomSeed });
const bucketExpr = opts.kind === 'letter' ? letterJumpBucketExpr(source.value) : sql`coalesce((${source.value})::text, '__unknown__')`; const bucketExpr = opts.kind === 'letter' ? letterJumpBucketExpr(source.value) : sql`coalesce((${source.value})::text, '__unknown__')`;
const isUnknownExpr = opts.kind === 'category' ? sql`${source.value} IS NULL` : sql`false`; const isUnknownExpr = opts.kind === 'category' ? sql`${source.value} IS NULL` : sql`false`;
const result = await this.db.execute<DiscreteJumpBucketRawRow>( const result = await this.db.execute<DiscreteJumpBucketRawRow>(
+3 -3
View File
@@ -2742,7 +2742,7 @@ describe('BookService', () => {
} as never); } as never);
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(42, 7, false); expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(42, 7, false);
expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith([{ field: 'title', dir: 'asc' }], 42, undefined); expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith([{ field: 'title', dir: 'asc' }], 42, undefined, {});
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith( expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(
expect.objectContaining({ where: 'WHERE', field: 'title', kind: 'letter', userId: 42, maxBuckets: 24, orderBy: ['ORDER'] }), expect.objectContaining({ where: 'WHERE', field: 'title', kind: 'letter', userId: 42, maxBuckets: 24, orderBy: ['ORDER'] }),
); );
@@ -4480,7 +4480,7 @@ describe('BookService', () => {
const result = await service.executeBooksQuery(12, undefined, query); const result = await service.executeBooksQuery(12, undefined, query);
expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith(query.sort, 12, undefined); expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith(query.sort, 12, undefined, {});
expect(bookRepo.findCards).toHaveBeenCalledWith({ expect(bookRepo.findCards).toHaveBeenCalledWith({
where: undefined, where: undefined,
orderBy: 'order-by', orderBy: 'order-by',
@@ -4605,7 +4605,7 @@ describe('BookService', () => {
await service.executeBooksQuery(12, undefined, query); await service.executeBooksQuery(12, undefined, query);
expect(customMetadataService.getActiveFieldTypes).toHaveBeenCalledWith([7]); expect(customMetadataService.getActiveFieldTypes).toHaveBeenCalledWith([7]);
expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith(query.sort, 12, fieldTypes); expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith(query.sort, 12, fieldTypes, {});
}); });
it('does not query custom metadata field types when no custom sort is used', async () => { it('does not query custom metadata field types when no custom sort is used', async () => {
+11 -7
View File
@@ -1095,6 +1095,7 @@ export class BookService {
userId, userId,
customFieldTypes, customFieldTypes,
...(options?.defaultCollectionId !== undefined ? { defaultCollectionId: options.defaultCollectionId } : {}), ...(options?.defaultCollectionId !== undefined ? { defaultCollectionId: options.defaultCollectionId } : {}),
...(query.randomSeed !== undefined ? { randomSeed: query.randomSeed } : {}),
}); });
// Collapsed rows render BookTableCollapsedSeriesCell which does not display custom metadata. // Collapsed rows render BookTableCollapsedSeriesCell which does not display custom metadata.
const result = { const result = {
@@ -1122,10 +1123,10 @@ export class BookService {
return result; return result;
} }
const orderBy = const orderBy = this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes, {
options?.defaultCollectionId !== undefined ...(options?.defaultCollectionId !== undefined ? { defaultCollectionId: options.defaultCollectionId } : {}),
? this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes, { defaultCollectionId: options.defaultCollectionId }) ...(query.randomSeed !== undefined ? { randomSeed: query.randomSeed } : {}),
: this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes); });
const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows, seriesMembershipRows, total } = const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows, seriesMembershipRows, total } =
await this.bookRepo.findCards({ await this.bookRepo.findCards({
where, where,
@@ -1164,7 +1165,9 @@ export class BookService {
async executeBookIdsQuery(userId: number, where: SQL | undefined, query: BookQuery): Promise<number[]> { async executeBookIdsQuery(userId: number, where: SQL | undefined, query: BookQuery): Promise<number[]> {
const customFieldTypes = await this.resolveCustomSortFieldTypes(query.sort); const customFieldTypes = await this.resolveCustomSortFieldTypes(query.sort);
const orderBy = this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes); const orderBy = this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes, {
...(query.randomSeed !== undefined ? { randomSeed: query.randomSeed } : {}),
});
return this.bookRepo.findCardIds({ return this.bookRepo.findCardIds({
where, where,
orderBy, orderBy,
@@ -1231,11 +1234,12 @@ export class BookService {
// rows inside a bucket, so they can reference custom fields. The temporal path // rows inside a bucket, so they can reference custom fields. The temporal path
// never applies the full sort, which is why this only matters here. // never applies the full sort, which is why this only matters here.
const customFieldTypes = await this.resolveCustomSortFieldTypes(query.sort); const customFieldTypes = await this.resolveCustomSortFieldTypes(query.sort);
const randomSeedOption = query.randomSeed !== undefined ? { randomSeed: query.randomSeed } : {};
response = shouldCollapse response = shouldCollapse
? await this.bookRepo.findJumpBucketsCollapsed({ ...discreteOpts, sort: query.sort, customFieldTypes }) ? await this.bookRepo.findJumpBucketsCollapsed({ ...discreteOpts, sort: query.sort, customFieldTypes, ...randomSeedOption })
: await this.bookRepo.findJumpBuckets({ : await this.bookRepo.findJumpBuckets({
...discreteOpts, ...discreteOpts,
orderBy: this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes), orderBy: this.queryBuilder.buildOrderBy(query.sort, userId, customFieldTypes, randomSeedOption),
}); });
} }
this.logger.log( this.logger.log(
@@ -61,6 +61,21 @@ describe('BookQueryPipe', () => {
expect(result.filter).toBeDefined(); expect(result.filter).toBeDefined();
}); });
it('accepts a shuffle seed', () => {
const result = pipe.transform({ sort: [{ field: 'random', dir: 'asc' }], randomSeed: 987654 });
expect(result.randomSeed).toBe(987654);
});
it('leaves the shuffle seed undefined when the caller omits it', () => {
expect(pipe.transform({ sort: [{ field: 'random', dir: 'asc' }] }).randomSeed).toBeUndefined();
});
it('throws BadRequestException for an out-of-range shuffle seed', () => {
expect(() => pipe.transform({ randomSeed: 2_147_483_648 })).toThrow(BadRequestException);
expect(() => pipe.transform({ randomSeed: -1 })).toThrow(BadRequestException);
expect(() => pipe.transform({ randomSeed: 1.5 })).toThrow(BadRequestException);
});
it('throws BadRequestException for invalid sort field', () => { it('throws BadRequestException for invalid sort field', () => {
expect(() => pipe.transform({ sort: [{ field: 'unknownField', dir: 'asc' }] })).toThrow(BadRequestException); expect(() => pipe.transform({ sort: [{ field: 'unknownField', dir: 'asc' }] })).toThrow(BadRequestException);
}); });
@@ -1,7 +1,7 @@
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
import { z } from 'zod'; import { z } from 'zod';
import { isSortField, type BookQuery, type JumpBucketsQuery, type SortField } from '@bookorbit/types'; import { MAX_RANDOM_SORT_SEED, isSortField, type BookQuery, type JumpBucketsQuery, type SortField } from '@bookorbit/types';
import { MAX_BOOK_QUERY_OFFSET_ROWS, isBookQueryOffsetWithinLimit } from '../../../common/constants/pagination.constants'; import { MAX_BOOK_QUERY_OFFSET_ROWS, isBookQueryOffsetWithinLimit } from '../../../common/constants/pagination.constants';
import { groupRuleSchema } from '../utils/group-rule.validator'; import { groupRuleSchema } from '../utils/group-rule.validator';
@@ -12,6 +12,7 @@ const bookQuerySchema = z.object({
collapseSeries: z.boolean().optional(), collapseSeries: z.boolean().optional(),
filter: groupRuleSchema(5).optional(), filter: groupRuleSchema(5).optional(),
q: z.string().max(200).optional(), q: z.string().max(200).optional(),
randomSeed: z.number().int().min(0).max(MAX_RANDOM_SORT_SEED).optional(),
sort: z sort: z
.array( .array(
z.object({ z.object({