feat(books): add support for multiple series per book

This introduces a many-to-many relationship between books and series, resolving the previous limitation where a book could only belong to a single series. It updates the database schema, query builders, server DTOs, and the Vue client interface to manage multiple series memberships per book.

Closes #353
This commit is contained in:
neon
2026-06-17 06:15:59 +00:00
parent 9b4408c610
commit 9afddc34d2
36 changed files with 12218 additions and 221 deletions
@@ -1,13 +1,15 @@
<script setup lang="ts">
import { reactive, computed, watch } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { X } from 'lucide-vue-next'
import ChipInput from '@/components/ui/ChipInput.vue'
import InputWithSuggestions from '@/components/ui/InputWithSuggestions.vue'
import SeriesMembershipEditor from '@/features/book/components/detail/tabs/SeriesMembershipEditor.vue'
import { useAuthorSearch } from '@/features/book/composables/useAuthorSearch'
import { useGenreSearch, useTagSearch } from '@/features/book/composables/useTagSearch'
import { useNarratorSearch } from '@/features/book/composables/useNarratorSearch'
import { usePublisherSearch, useSeriesNameSearch, useLanguageSearch } from '@/features/book/composables/useMetadataFieldSearch'
import type { BulkEditFields, ArrayMode } from '@/features/book/composables/useBulkEditMetadata'
import { normalizeSeriesMemberships, type EditableSeriesMembership } from '@/features/book/composables/useMetadataEditor'
import {
BULK_EDITABLE_ARRAY_FIELDS,
BULK_EDITABLE_SCALAR_FIELDS,
@@ -98,6 +100,7 @@ const scalarValues = reactive<Record<ScalarFieldKey, string>>({
language: '',
publishedYear: '',
})
const seriesMembershipValues = ref<EditableSeriesMembership[]>([])
const hasAnyEnabledField = computed(() => Object.values(enabledFields).some(Boolean))
@@ -136,6 +139,10 @@ function buildFields(): BulkEditFields {
for (const key of SCALAR_FIELDS) {
if (!enabledFields[key]) continue
if (key === 'seriesName') {
fields.seriesMemberships = normalizeSeriesMemberships(seriesMembershipValues.value)
continue
}
if (key === 'publishedYear') {
const num = scalarValues[key].trim() ? parseInt(scalarValues[key], 10) : null
fields[key] = { value: num !== null && Number.isNaN(num) ? null : num }
@@ -163,6 +170,7 @@ function resetForm() {
for (const key of SCALAR_FIELDS) {
scalarValues[key] = ''
}
seriesMembershipValues.value = []
}
function handleClose() {
@@ -178,6 +186,10 @@ function setArrayMode(key: ArrayFieldKey, mode: UIArrayMode) {
arrayModes[key] = mode
}
function handleSeriesMembershipsUpdate(values: EditableSeriesMembership[]) {
seriesMembershipValues.value = values
}
watch(
() => props.open,
(open) => {
@@ -291,8 +303,14 @@ watch(
</button>
<div v-if="enabledFields[key]" class="pl-6">
<SeriesMembershipEditor
v-if="key === 'seriesName'"
:model-value="seriesMembershipValues"
:search-fn="searchSeries"
@update:model-value="handleSeriesMembershipsUpdate"
/>
<InputWithSuggestions
v-if="SUGGESTION_FNS[key]"
v-else-if="SUGGESTION_FNS[key]"
:model-value="scalarValues[key] || null"
:search-fn="SUGGESTION_FNS[key]!"
:placeholder="`Enter ${FIELD_LABELS[key].toLowerCase()}`"
@@ -23,9 +23,10 @@ import ChipInput from '@/components/ui/ChipInput.vue'
import CoverEditorPanel from './CoverEditorPanel.vue'
import MetadataSearchDrawer from './MetadataSearchDrawer.vue'
import MetadataFieldLabel from './MetadataFieldLabel.vue'
import SeriesMembershipEditor from './SeriesMembershipEditor.vue'
import WriteAndRenameResultPanel from '../WriteAndRenameResultPanel.vue'
import type { MetadataPatch } from '../../../composables/useMetadataDiff'
import { useMetadataEditor } from '../../../composables/useMetadataEditor'
import { type EditableSeriesMembership, useMetadataEditor } from '../../../composables/useMetadataEditor'
import { type MetadataRefreshPreview, useRefreshMetadata } from '../../../composables/useRefreshMetadata'
import { type FileMetadata, useFileMetadata } from '../../../composables/useFileMetadata'
import { useWriteAndRename } from '../../../composables/useWriteAndRename'
@@ -142,6 +143,7 @@ const {
markPersisted: markLocksPersisted,
isLocked,
isUpdating: isUpdatingLock,
replace: replaceLocks,
toggle,
lockAll,
unlockAll,
@@ -183,16 +185,6 @@ function setIntField(field: 'publishedYear' | 'pageCount' | 'durationSeconds', e
form[field] = isNaN(n) ? null : n
}
function setFloatField(field: 'seriesIndex', e: Event) {
const val = (e.target as HTMLInputElement).value
if (val === '') {
form[field] = null
return
}
const n = parseFloat(val)
form[field] = isNaN(n) ? null : n
}
watch(
() => props.book,
(book) => {
@@ -205,6 +197,7 @@ watch(
const combinedError = computed(() => lockError.value ?? error.value)
const hasLockedFields = computed(() => lockedFields.value.length > 0)
const hasPendingChanges = computed(() => isDirty.value || locksDirty.value)
const isSeriesLocked = computed(() => isLocked('seriesName') || isLocked('seriesIndex'))
async function submit() {
if (coverPanel.value?.hasPending) {
@@ -254,8 +247,54 @@ function trackLockedField(field: BookMetadataLockField, skippedFields: BookMetad
}
}
function normalizeSeriesIndex(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number.parseFloat(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function setSeriesMemberships(memberships: EditableSeriesMembership[]) {
form.seriesMemberships = memberships
const primary = memberships.find((membership) => membership.seriesName.trim())
form.seriesName = primary?.seriesName.trim() || null
form.seriesIndex = primary?.seriesIndex ?? null
}
function handleSeriesMembershipsUpdate(memberships: EditableSeriesMembership[]) {
setSeriesMemberships(memberships)
}
function applyPrimarySeriesPatch(field: 'seriesName' | 'seriesIndex', value: unknown, skippedFields: BookMetadataLockField[]): boolean {
if (isSeriesLocked.value) {
if (isLocked('seriesName')) trackLockedField('seriesName', skippedFields)
if (isLocked('seriesIndex')) trackLockedField('seriesIndex', skippedFields)
return false
}
const next = [...form.seriesMemberships]
const primary = next[0] ?? { seriesName: form.seriesName ?? '', seriesIndex: form.seriesIndex ?? null }
const patched =
field === 'seriesName'
? { ...primary, seriesName: typeof value === 'string' ? value : value == null ? '' : String(value) }
: { ...primary, seriesIndex: normalizeSeriesIndex(value) }
if (next.length > 0) {
next[0] = patched
} else {
next.push(patched)
}
setSeriesMemberships(next)
return true
}
function applyDirectPatchField(field: (typeof DIRECT_PATCH_FIELDS)[number], value: unknown, skippedFields: BookMetadataLockField[]): boolean {
if (value === undefined) return false
if (field === 'seriesName' || field === 'seriesIndex') {
return applyPrimarySeriesPatch(field, value, skippedFields)
}
if (isLocked(field)) {
trackLockedField(field, skippedFields)
return false
@@ -491,6 +530,14 @@ async function handleLockToggle(field: BookMetadataLockField) {
await toggle(props.book.id, field)
}
async function handleSeriesLockToggle() {
const seriesFields: BookMetadataLockField[] = ['seriesName', 'seriesIndex']
const next = isSeriesLocked.value
? lockedFields.value.filter((field) => !seriesFields.includes(field))
: [...new Set([...lockedFields.value, ...seriesFields])]
await replaceLocks(props.book.id, next, 'seriesName')
}
function handleCoverLockToggle() {
handleLockToggle('cover')
}
@@ -831,43 +878,25 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
</MetadataFieldLabel>
</div>
<!-- Series | Index | Publisher -->
<div class="grid grid-cols-[1fr_7rem] sm:flex sm:flex-wrap gap-3">
<!-- Series | Publisher -->
<div class="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_minmax(12rem,18rem)] gap-3">
<MetadataFieldLabel
class="min-w-0 sm:flex-1 sm:min-w-35"
label="Series"
field="seriesName"
:locked="isLocked('seriesName')"
:locked="isSeriesLocked"
:is-updating="isUpdatingLock"
@toggle="handleLockToggle"
multiline
@toggle="handleSeriesLockToggle"
>
<InputWithSuggestions
v-model="form.seriesName"
<SeriesMembershipEditor
class="pr-10"
:model-value="form.seriesMemberships"
:search-fn="searchSeriesName"
:disabled="isLocked('seriesName')"
:class="'w-full h-8 rounded-lg border border-input bg-background px-3 pr-12 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed'"
:disabled="isSeriesLocked"
@update:model-value="handleSeriesMembershipsUpdate"
/>
</MetadataFieldLabel>
<MetadataFieldLabel
class="sm:w-28 sm:shrink-0"
label="Index"
field="seriesIndex"
:locked="isLocked('seriesIndex')"
:is-updating="isUpdatingLock"
@toggle="handleLockToggle"
>
<input
:value="form.seriesIndex ?? ''"
type="number"
step="0.1"
min="0"
class="w-full h-8 rounded-lg border border-input bg-background px-3 pr-12 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLocked('seriesIndex')"
@input="setFloatField('seriesIndex', $event)"
/>
</MetadataFieldLabel>
<MetadataFieldLabel
class="col-span-2 sm:flex-1 sm:min-w-30"
label="Publisher"
field="publisher"
:locked="isLocked('publisher')"
@@ -28,7 +28,7 @@ function handleToggle() {
<button
type="button"
class="absolute right-2 z-10 inline-flex size-6 cursor-pointer items-center justify-center rounded-md border border-input bg-background/95 text-muted-foreground shadow-sm transition-[colors,opacity] hover:text-foreground hover:bg-muted disabled:cursor-not-allowed"
:class="[multiline ? 'top-2.5' : 'top-1/2 -translate-y-1/2', loading ? 'opacity-60' : 'opacity-100']"
:class="[multiline ? 'top-1' : 'top-1/2 -translate-y-1/2', loading ? 'opacity-60' : 'opacity-100']"
:aria-label="locked ? `Unlock ${label}` : `Lock ${label}`"
:title="locked ? `Unlock ${label}` : `Lock ${label}`"
:disabled="loading"
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-vue-next'
import InputWithSuggestions from '@/components/ui/InputWithSuggestions.vue'
import type { EditableSeriesMembership } from '../../../composables/useMetadataEditor'
const props = defineProps<{
modelValue: EditableSeriesMembership[]
searchFn: (q: string) => Promise<string[]>
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [EditableSeriesMembership[]] }>()
const visibleMemberships = computed<EditableSeriesMembership[]>(() =>
props.modelValue.length > 0 ? props.modelValue : [{ seriesName: '', seriesIndex: null }],
)
function updateMembership(index: number, patch: Partial<EditableSeriesMembership>) {
if (props.disabled) return
if (props.modelValue.length === 0 && index === 0) {
emit('update:modelValue', [{ seriesName: '', seriesIndex: null, ...patch }])
return
}
const next = props.modelValue.map((membership, i) => (i === index ? { ...membership, ...patch } : membership))
emit('update:modelValue', next)
}
function updateSeriesName(index: number, value: string | null) {
updateMembership(index, { seriesName: value ?? '' })
}
function updateSeriesIndex(index: number, event: Event) {
const raw = (event.target as HTMLInputElement).value
updateMembership(index, { seriesIndex: raw === '' ? null : Number.parseFloat(raw) })
}
function addMembership() {
if (props.disabled) return
emit('update:modelValue', [...props.modelValue, { seriesName: '', seriesIndex: null }])
}
function removeMembership(index: number) {
if (props.disabled) return
emit(
'update:modelValue',
props.modelValue.filter((_, i) => i !== index),
)
}
function moveMembership(index: number, offset: -1 | 1) {
if (props.disabled) return
const target = index + offset
if (target < 0 || target >= props.modelValue.length) return
const next = [...props.modelValue]
const [item] = next.splice(index, 1)
if (!item) return
next.splice(target, 0, item)
emit('update:modelValue', next)
}
</script>
<template>
<div class="flex flex-col gap-2">
<div
v-for="(membership, index) in visibleMemberships"
:key="modelValue.length === 0 ? 'empty-series-row' : `${index}:${membership.seriesName}`"
class="grid grid-cols-[2rem_minmax(0,1fr)_5.5rem_auto] items-center gap-2"
>
<button
v-if="index === 0"
type="button"
class="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
:disabled="disabled"
title="Add series"
@click="addMembership"
>
<Plus class="size-3.5" />
</button>
<div v-else class="h-8 w-8" aria-hidden="true" />
<InputWithSuggestions
:model-value="membership.seriesName"
:search-fn="searchFn"
:disabled="disabled"
placeholder="Series"
:class="'h-8 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-shadow focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50'"
@update:model-value="updateSeriesName(index, $event)"
/>
<input
:value="membership.seriesIndex ?? ''"
type="number"
step="0.1"
min="0"
class="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm outline-none transition-shadow focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
:disabled="disabled"
placeholder="#"
@input="updateSeriesIndex(index, $event)"
/>
<div class="flex items-center gap-1">
<button
type="button"
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
:disabled="disabled || index === 0"
title="Move up"
@click="moveMembership(index, -1)"
>
<ArrowUp class="size-3.5" />
</button>
<button
type="button"
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
:disabled="disabled || modelValue.length < 2 || index === visibleMemberships.length - 1"
title="Move down"
@click="moveMembership(index, 1)"
>
<ArrowDown class="size-3.5" />
</button>
<button
type="button"
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-40"
:disabled="disabled || modelValue.length === 0"
title="Remove series"
@click="removeMembership(index)"
>
<Trash2 class="size-3.5" />
</button>
</div>
</div>
</div>
</template>
@@ -3,6 +3,7 @@ import { api } from '@/lib/api'
import { toast } from 'vue-sonner'
import type { Ref } from 'vue'
import type { BookCard, SortSpec, GroupRule } from '@bookorbit/types'
import type { EditableSeriesMembership } from './useMetadataEditor'
export type ArrayMode = 'add' | 'remove' | 'replace'
@@ -25,6 +26,7 @@ export type BulkEditFields = {
tags?: BulkEditArrayField
narrators?: BulkEditArrayField
seriesName?: BulkEditScalarStringField
seriesMemberships?: EditableSeriesMembership[]
publisher?: BulkEditScalarStringField
language?: BulkEditScalarStringField
publishedYear?: BulkEditScalarNumberField
@@ -129,6 +131,12 @@ function applyOptimisticUpdates(fields: BulkEditFields, ids: number[], books: Re
if (fields.seriesName !== undefined) {
updated.seriesName = fields.seriesName.value
updated.seriesIndex = null
}
if (fields.seriesMemberships !== undefined) {
const primary = fields.seriesMemberships[0]
updated.seriesName = primary?.seriesName ?? null
updated.seriesIndex = primary?.seriesIndex ?? null
}
if (fields.publisher !== undefined) {
updated.publisher = fields.publisher.value
@@ -2,6 +2,11 @@ import { computed, reactive, ref } from 'vue'
import { api } from '@/lib/api'
import { FORMAT_TO_GROUP, type BookDetail, type BookMetadataLockField, type BookMetadataSaveResult } from '@bookorbit/types'
export type EditableSeriesMembership = {
seriesName: string
seriesIndex: number | null
}
const ROOT_FIELDS = [
'title',
'subtitle',
@@ -10,8 +15,6 @@ const ROOT_FIELDS = [
'publishedYear',
'language',
'pageCount',
'seriesName',
'seriesIndex',
'isbn10',
'isbn13',
'rating',
@@ -55,6 +58,28 @@ function normalizePageCount(value: number | null): number | null {
return typeof value === 'number' && value > 0 ? value : null
}
export function normalizeSeriesMemberships(values: readonly EditableSeriesMembership[]): EditableSeriesMembership[] {
const seen = new Set<string>()
const out: EditableSeriesMembership[] = []
for (const value of values) {
const seriesName = value.seriesName.trim()
if (!seriesName) continue
const key = seriesName.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
out.push({ seriesName, seriesIndex: value.seriesIndex ?? null })
}
return out
}
function seriesMembershipsFromBook(book: BookDetail): EditableSeriesMembership[] {
const memberships = book.seriesMemberships ?? []
if (memberships.length > 0) {
return normalizeSeriesMemberships(memberships.map((membership) => ({ seriesName: membership.seriesName, seriesIndex: membership.seriesIndex })))
}
return book.seriesName ? [{ seriesName: book.seriesName, seriesIndex: book.seriesIndex }] : []
}
export function useMetadataEditor() {
const saving = ref(false)
const error = ref<string | null>(null)
@@ -69,6 +94,7 @@ export function useMetadataEditor() {
pageCount: null as number | null,
seriesName: null as string | null,
seriesIndex: null as number | null,
seriesMemberships: [] as EditableSeriesMembership[],
isbn10: null as string | null,
isbn13: null as string | null,
rating: null as number | null,
@@ -117,6 +143,7 @@ export function useMetadataEditor() {
form.pageCount = book.pageCount
form.seriesName = book.seriesName
form.seriesIndex = book.seriesIndex
form.seriesMemberships = seriesMembershipsFromBook(book)
form.isbn10 = book.isbn10
form.isbn13 = book.isbn13
form.rating = book.rating ?? null
@@ -171,6 +198,12 @@ export function useMetadataEditor() {
}
}
const currentSeriesMemberships = normalizeSeriesMemberships(form.seriesMemberships)
const previousSeriesMemberships = normalizeSeriesMemberships(previous.seriesMemberships)
if (JSON.stringify(currentSeriesMemberships) !== JSON.stringify(previousSeriesMemberships)) {
payload.seriesMemberships = currentSeriesMemberships
}
const comicMetadata: Record<string, unknown> = {}
for (const [payloadKey, formKey] of Object.entries(COMIC_FIELDS)) {
if (JSON.stringify(form[formKey]) !== JSON.stringify(previous[formKey])) {
@@ -23,6 +23,7 @@ export function mergeBookCardWithDetail(book: BookCard, detail: BookDetail): Boo
pageCount: detail.pageCount,
seriesName: detail.seriesName,
seriesIndex: detail.seriesIndex,
seriesMemberships: detail.seriesMemberships,
rating: detail.rating,
authors: detail.authors.map((author) => author.name),
genres: detail.genres,
+10
View File
@@ -36,6 +36,13 @@ export type BookFileRef = {
sizeBytes: number | null;
};
export type BookSeriesMembership = {
seriesId: number;
seriesName: string;
seriesIndex: number | null;
displayOrder: number;
};
export type BookCard = {
id: number;
status: string;
@@ -44,6 +51,7 @@ export type BookCard = {
seriesId?: number | null;
seriesName: string | null;
seriesIndex: number | null;
seriesMemberships?: BookSeriesMembership[];
files: BookFileRef[];
publishedYear: number | null;
language: string | null;
@@ -119,6 +127,7 @@ export type BookDetail = {
seriesId?: number | null;
seriesName: string | null;
seriesIndex: number | null;
seriesMemberships?: BookSeriesMembership[];
rating: number | null;
coverSource: "extracted" | "custom" | null;
providerIds: ProviderIds;
@@ -155,6 +164,7 @@ export type BookMetadataRefreshPreviewFields = {
pageCount?: number | null;
seriesName?: string | null;
seriesIndex?: number | null;
seriesMemberships?: { seriesName: string; seriesIndex?: number | null }[] | null;
coverUrl?: string;
googleBooksId?: string | null;
goodreadsId?: string | null;
+3 -2
View File
@@ -2,11 +2,12 @@ import { Global, Module } from '@nestjs/common';
import { DbModule } from '../db/db.module';
import { SeriesIdentityService } from './services/series-identity.service';
import { SeriesMembershipService } from './services/series-membership.service';
@Global()
@Module({
imports: [DbModule],
providers: [SeriesIdentityService],
exports: [SeriesIdentityService],
providers: [SeriesIdentityService, SeriesMembershipService],
exports: [SeriesIdentityService, SeriesMembershipService],
})
export class SeriesIdentityModule {}
@@ -117,12 +117,15 @@ describe('SeriesIdentityService', () => {
const transaction = vi.fn().mockImplementation(async (cb: (tx: typeof tx) => Promise<void>) => cb(tx));
const service = new SeriesIdentityService({ transaction } as never);
const backfill = vi.spyOn(service, 'backfillMissingSeriesIds').mockResolvedValue(undefined);
const backfillMemberships = vi.spyOn(service, 'backfillMissingSeriesMemberships').mockResolvedValue(undefined);
await service.onModuleInit();
expect(transaction).toHaveBeenCalledTimes(1);
expect(backfill).toHaveBeenCalledTimes(1);
expect(backfill).toHaveBeenCalledWith(tx);
expect(backfillMemberships).toHaveBeenCalledTimes(1);
expect(backfillMemberships).toHaveBeenCalledWith(tx);
});
it('disables statement timeout before running backfill', async () => {
@@ -131,6 +134,7 @@ describe('SeriesIdentityService', () => {
const transaction = vi.fn().mockImplementation(async (cb: (tx: typeof tx) => Promise<void>) => cb(tx));
const service = new SeriesIdentityService({ transaction } as never);
vi.spyOn(service, 'backfillMissingSeriesIds').mockResolvedValue(undefined);
vi.spyOn(service, 'backfillMissingSeriesMemberships').mockResolvedValue(undefined);
await service.onModuleInit();
@@ -4,7 +4,7 @@ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookMetadata, bookSeries } from '../../db/schema';
import { bookMetadata, bookSeries, bookSeriesMemberships } from '../../db/schema';
import { sanitizeLogValue } from '../utils/log-sanitize.utils';
type Db = NodePgDatabase<typeof schema>;
@@ -22,8 +22,9 @@ export class SeriesIdentityService implements OnModuleInit {
await this.db.transaction(async (tx) => {
await tx.execute(sql`SET LOCAL statement_timeout = 0`);
await this.backfillMissingSeriesIds(tx);
await this.backfillMissingSeriesMemberships(tx);
});
this.logger.log(`[series.backfill] [end] durationMs=${Date.now() - start} - series id backfill completed`);
this.logger.log(`[series.backfill] [end] durationMs=${Date.now() - start} - series id and membership backfill completed`);
} catch (err) {
const e = err as Error & { cause?: unknown };
const cause = e.cause instanceof Error ? e.cause.message : e.message;
@@ -100,6 +101,21 @@ export class SeriesIdentityService implements OnModuleInit {
`);
}
async backfillMissingSeriesMemberships(executor: Pick<Db, 'execute'> = this.db): Promise<void> {
await executor.execute(sql`
INSERT INTO ${bookSeriesMemberships} (book_id, series_id, series_index, display_order)
SELECT ${bookMetadata.bookId}, ${bookMetadata.seriesId}, ${bookMetadata.seriesIndex}, 0
FROM ${bookMetadata}
WHERE ${bookMetadata.seriesId} IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM ${bookSeriesMemberships}
WHERE ${bookSeriesMemberships.bookId} = ${bookMetadata.bookId}
)
ON CONFLICT DO NOTHING
`);
}
async findIdByName(name: string, executor: Pick<Db, 'select'> = this.db): Promise<number | null> {
const normalizedName = this.normalizeName(name);
if (!normalizedName) return null;
@@ -0,0 +1,120 @@
import { SeriesMembershipService } from './series-membership.service';
function makeSeriesIdentity(ids: Record<string, number>) {
return {
normalizeDisplayName: vi.fn((name: string | null | undefined) => {
const trimmed = name?.trim();
return trimmed || null;
}),
normalizeName: vi.fn((name: string | null | undefined) => {
const trimmed = name?.trim();
return trimmed ? trimmed.toLowerCase() : null;
}),
resolveSeriesId: vi.fn((name: string | null | undefined) => {
const key = name?.trim().toLowerCase() ?? '';
return Promise.resolve(ids[key] ?? null);
}),
};
}
function makeMutationDb() {
const deleteWhere = vi.fn().mockResolvedValue(undefined);
const deleteFrom = vi.fn().mockReturnValue({ where: deleteWhere });
const insertValues = vi.fn().mockResolvedValue(undefined);
const insertInto = vi.fn().mockReturnValue({ values: insertValues });
const updateWhere = vi.fn().mockResolvedValue(undefined);
const updateSet = vi.fn().mockReturnValue({ where: updateWhere });
const update = vi.fn().mockReturnValue({ set: updateSet });
return {
db: { delete: deleteFrom, insert: insertInto, update } as never,
deleteFrom,
deleteWhere,
insertInto,
insertValues,
update,
updateSet,
updateWhere,
};
}
function makeSelectChain<T>(rows: T[]) {
const orderBy = vi.fn().mockResolvedValue(rows);
const limit = vi.fn().mockResolvedValue(rows);
const where = vi.fn().mockReturnValue({ orderBy, limit });
const innerJoin = vi.fn().mockReturnValue({ where });
const from = vi.fn().mockReturnValue({ innerJoin, where });
const select = vi.fn().mockReturnValue({ from });
return { select, from, innerJoin, where, orderBy, limit };
}
describe('SeriesMembershipService', () => {
it('replaces memberships with normalized unique series and syncs primary scalar metadata', async () => {
const identity = makeSeriesIdentity({ dune: 101, 'the expanse': 202 });
const { db, deleteFrom, insertValues, updateSet } = makeMutationDb();
const service = new SeriesMembershipService(db, identity as never);
const result = await service.replaceForBook(
10,
[
{ seriesName: ' Dune ', seriesIndex: 1 },
{ seriesName: 'dune', seriesIndex: 2 },
{ seriesName: 'The Expanse', seriesIndex: null },
{ seriesName: ' ', seriesIndex: 3 },
],
db,
);
expect(result).toEqual([
{ seriesId: 101, seriesName: 'Dune', seriesIndex: 1, displayOrder: 0 },
{ seriesId: 202, seriesName: 'The Expanse', seriesIndex: null, displayOrder: 1 },
]);
expect(deleteFrom).toHaveBeenCalledTimes(1);
expect(identity.resolveSeriesId).toHaveBeenCalledTimes(2);
expect(insertValues).toHaveBeenCalledWith([
{ bookId: 10, seriesId: 101, seriesIndex: 1, displayOrder: 0 },
{ bookId: 10, seriesId: 202, seriesIndex: null, displayOrder: 1 },
]);
expect(updateSet).toHaveBeenNthCalledWith(1, {
seriesId: 101,
seriesName: 'Dune',
seriesIndex: 1,
updatedAt: expect.any(Date),
});
expect(updateSet).toHaveBeenNthCalledWith(2, { updatedAt: expect.any(Date) });
});
it('does not insert membership rows when replacement normalizes to empty', async () => {
const identity = makeSeriesIdentity({});
const { db, insertInto, updateSet } = makeMutationDb();
const service = new SeriesMembershipService(db, identity as never);
await expect(service.replaceForBook(10, [{ seriesName: 'unknown' }], db)).resolves.toEqual([]);
expect(insertInto).not.toHaveBeenCalled();
expect(updateSet).toHaveBeenNthCalledWith(1, {
seriesId: null,
seriesName: null,
seriesIndex: null,
updatedAt: expect.any(Date),
});
});
it('promotes remaining memberships when legacy scalar metadata is cleared', async () => {
const identity = makeSeriesIdentity({});
const metadataChain = makeSelectChain([{ seriesName: null, seriesIndex: null, seriesId: null }]);
const membershipChain = makeSelectChain([
{ bookId: 7, seriesId: 1, seriesName: 'Primary', seriesIndex: 1, displayOrder: 0 },
{ bookId: 7, seriesId: 2, seriesName: 'Rest', seriesIndex: 2, displayOrder: 1 },
]);
const db = {
select: vi.fn().mockReturnValueOnce(metadataChain).mockReturnValueOnce(membershipChain),
};
const service = new SeriesMembershipService(db as never, identity as never);
const replaceForBook = vi.spyOn(service, 'replaceForBook').mockResolvedValue([]);
await service.syncPrimaryFromMetadata(7, db as never);
expect(replaceForBook).toHaveBeenCalledWith(7, [{ seriesName: 'Rest', seriesIndex: 2 }], db);
});
});
@@ -0,0 +1,192 @@
import { Inject, Injectable } from '@nestjs/common';
import { asc, eq, inArray, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookMetadata, bookSeries, bookSeriesMemberships, books } from '../../db/schema';
import { SeriesIdentityService } from './series-identity.service';
type Db = NodePgDatabase<typeof schema>;
type DbTransaction = Parameters<Parameters<Db['transaction']>[0]>[0];
type SeriesMembershipExecutor = Pick<Db, 'delete' | 'insert' | 'select' | 'update'> | DbTransaction;
type SeriesMembershipReadExecutor = Pick<Db, 'select'> | DbTransaction;
export type SeriesMembershipInput = {
seriesName: string | null;
seriesIndex?: number | null;
};
export type SeriesMembershipRef = {
seriesId: number;
seriesName: string;
seriesIndex: number | null;
displayOrder: number;
};
@Injectable()
export class SeriesMembershipService {
constructor(
@Inject(DB) private readonly db: Db,
private readonly seriesIdentity: SeriesIdentityService,
) {}
async replaceForBook(
bookId: number,
memberships: readonly SeriesMembershipInput[] | null | undefined,
executor: SeriesMembershipExecutor = this.db,
): Promise<SeriesMembershipRef[]> {
const normalized = await this.normalizeMemberships(memberships ?? [], executor);
await executor.delete(bookSeriesMemberships).where(eq(bookSeriesMemberships.bookId, bookId));
if (normalized.length > 0) {
await executor.insert(bookSeriesMemberships).values(
normalized.map((membership) => ({
bookId,
seriesId: membership.seriesId,
seriesIndex: membership.seriesIndex,
displayOrder: membership.displayOrder,
})),
);
}
await this.syncPrimaryMetadata(bookId, normalized, executor);
return normalized;
}
async syncPrimaryFromMetadata(bookId: number, executor: SeriesMembershipExecutor = this.db): Promise<SeriesMembershipRef[]> {
const [metadata] = await executor
.select({
seriesId: bookMetadata.seriesId,
seriesName: bookMetadata.seriesName,
seriesIndex: bookMetadata.seriesIndex,
})
.from(bookMetadata)
.where(eq(bookMetadata.bookId, bookId))
.limit(1);
const current = await this.findByBookId(bookId, executor);
const rest = current.filter((membership) => membership.displayOrder !== 0);
const displayName = this.seriesIdentity.normalizeDisplayName(metadata?.seriesName);
if (!displayName) {
return this.replaceForBook(
bookId,
rest.map((membership) => ({ seriesName: membership.seriesName, seriesIndex: membership.seriesIndex })),
executor,
);
}
return this.replaceForBook(
bookId,
[
{ seriesName: displayName, seriesIndex: metadata?.seriesIndex ?? null },
...rest.map((membership) => ({ seriesName: membership.seriesName, seriesIndex: membership.seriesIndex })),
],
executor,
);
}
async syncPrimaryFromMetadataForBooks(bookIds: number[], executor: SeriesMembershipExecutor = this.db): Promise<void> {
for (const bookId of bookIds) {
await this.syncPrimaryFromMetadata(bookId, executor);
}
}
async findByBookId(bookId: number, executor: SeriesMembershipReadExecutor = this.db): Promise<SeriesMembershipRef[]> {
const map = await this.findByBookIds([bookId], executor);
return map.get(bookId) ?? [];
}
async findByBookIds(bookIds: number[], executor: SeriesMembershipReadExecutor = this.db): Promise<Map<number, SeriesMembershipRef[]>> {
const result = new Map<number, SeriesMembershipRef[]>();
if (bookIds.length === 0) return result;
const rows = await executor
.select({
bookId: bookSeriesMemberships.bookId,
seriesId: bookSeriesMemberships.seriesId,
seriesName: bookSeries.name,
seriesIndex: bookSeriesMemberships.seriesIndex,
displayOrder: bookSeriesMemberships.displayOrder,
})
.from(bookSeriesMemberships)
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
.where(inArray(bookSeriesMemberships.bookId, bookIds))
.orderBy(asc(bookSeriesMemberships.bookId), asc(bookSeriesMemberships.displayOrder), asc(bookSeriesMemberships.seriesId));
for (const row of rows) {
const list = result.get(row.bookId) ?? [];
list.push({
seriesId: row.seriesId,
seriesName: row.seriesName,
seriesIndex: row.seriesIndex,
displayOrder: row.displayOrder,
});
result.set(row.bookId, list);
}
return result;
}
async backfillFromMetadata(executor: Pick<Db, 'execute'> = this.db): Promise<void> {
await executor.execute(sql`
INSERT INTO ${bookSeriesMemberships} (book_id, series_id, series_index, display_order)
SELECT ${bookMetadata.bookId}, ${bookMetadata.seriesId}, ${bookMetadata.seriesIndex}, 0
FROM ${bookMetadata}
WHERE ${bookMetadata.seriesId} IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM ${bookSeriesMemberships}
WHERE ${bookSeriesMemberships.bookId} = ${bookMetadata.bookId}
)
ON CONFLICT DO NOTHING
`);
}
async syncPrimaryMetadata(
bookId: number,
memberships: readonly SeriesMembershipRef[],
executor: SeriesMembershipExecutor = this.db,
): Promise<void> {
const primary = memberships[0] ?? null;
await executor
.update(bookMetadata)
.set({
seriesId: primary?.seriesId ?? null,
seriesName: primary?.seriesName ?? null,
seriesIndex: primary?.seriesIndex ?? null,
updatedAt: new Date(),
})
.where(eq(bookMetadata.bookId, bookId));
await executor.update(books).set({ updatedAt: new Date() }).where(eq(books.id, bookId));
}
private async normalizeMemberships(
memberships: readonly SeriesMembershipInput[],
executor: Pick<Db, 'insert'> | DbTransaction,
): Promise<SeriesMembershipRef[]> {
const normalized: SeriesMembershipRef[] = [];
const seen = new Set<string>();
for (const membership of memberships) {
const displayName = this.seriesIdentity.normalizeDisplayName(membership.seriesName);
const normalizedName = this.seriesIdentity.normalizeName(displayName);
if (!displayName || !normalizedName || seen.has(normalizedName)) continue;
const seriesId = await this.seriesIdentity.resolveSeriesId(displayName, executor);
if (seriesId == null) continue;
seen.add(normalizedName);
normalized.push({
seriesId,
seriesName: displayName,
seriesIndex: membership.seriesIndex ?? null,
displayOrder: normalized.length,
});
}
return normalized;
}
}
@@ -0,0 +1,16 @@
CREATE TABLE "book_series_memberships" (
"book_id" integer NOT NULL,
"series_id" integer NOT NULL,
"series_index" real,
"display_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "book_series_memberships_book_id_series_id_pk" PRIMARY KEY("book_id","series_id"),
CONSTRAINT "book_series_memberships_display_order_nonnegative_chk" CHECK ("book_series_memberships"."display_order" >= 0)
);
--> statement-breakpoint
ALTER TABLE "book_series_memberships" ADD CONSTRAINT "book_series_memberships_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_series_memberships" ADD CONSTRAINT "book_series_memberships_series_id_book_series_id_fk" FOREIGN KEY ("series_id") REFERENCES "public"."book_series"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "book_series_memberships_book_display_uidx" ON "book_series_memberships" USING btree ("book_id","display_order");--> statement-breakpoint
CREATE INDEX "book_series_memberships_series_index_book_idx" ON "book_series_memberships" USING btree ("series_id","series_index","book_id");--> statement-breakpoint
CREATE INDEX "book_series_memberships_book_display_idx" ON "book_series_memberships" USING btree ("book_id","display_order");
File diff suppressed because it is too large Load Diff
@@ -148,6 +148,13 @@
"when": 1781495568135,
"tag": "0020_achievements_devices_and_session_source",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1781675709588,
"tag": "0021_add_book_series_memberships",
"breakpoints": true
}
]
}
+32 -1
View File
@@ -1,5 +1,7 @@
import { sql } from 'drizzle-orm';
import { index, pgTable, serial, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import { check, index, integer, pgTable, primaryKey, real, serial, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import { books } from './books';
export const bookSeries = pgTable(
'book_series',
@@ -22,3 +24,32 @@ export const bookSeries = pgTable(
export type BookSeries = typeof bookSeries.$inferSelect;
export type NewBookSeries = typeof bookSeries.$inferInsert;
export const bookSeriesMemberships = pgTable(
'book_series_memberships',
{
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
seriesId: integer('series_id')
.notNull()
.references(() => bookSeries.id, { onDelete: 'cascade' }),
seriesIndex: real('series_index'),
displayOrder: integer('display_order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
primaryKey({ columns: [t.bookId, t.seriesId] }),
uniqueIndex('book_series_memberships_book_display_uidx').on(t.bookId, t.displayOrder),
index('book_series_memberships_series_index_book_idx').on(t.seriesId, t.seriesIndex, t.bookId),
index('book_series_memberships_book_display_idx').on(t.bookId, t.displayOrder),
check('book_series_memberships_display_order_nonnegative_chk', sql`${t.displayOrder} >= 0`),
],
);
export type BookSeriesMembership = typeof bookSeriesMemberships.$inferSelect;
export type NewBookSeriesMembership = typeof bookSeriesMemberships.$inferInsert;
@@ -14,6 +14,7 @@ import type {
import { NotificationType, resolveUploadPath } from '@bookorbit/types';
import { NotificationService } from '../notification/notification.service';
import { SeriesIdentityService } from '../../common/services/series-identity.service';
import { SeriesMembershipService } from '../../common/services/series-membership.service';
import { formatSeriesIndex } from '../../common/utils/series-index-format.utils';
import { DB } from '../../db';
import * as schema from '../../db/schema';
@@ -72,6 +73,7 @@ export class BookDockFinalizeService implements OnModuleInit {
private readonly gateway: BookDockGateway,
private readonly notificationService: NotificationService,
@Optional() private readonly seriesIdentity?: SeriesIdentityService,
@Optional() private readonly seriesMemberships?: SeriesMembershipService,
) {}
onModuleInit() {
@@ -591,6 +593,7 @@ export class BookDockFinalizeService implements OnModuleInit {
.update(bookMetadata)
.set({ ...patch, ...buildAudioMetadataPatch(audio) })
.where(eq(bookMetadata.bookId, bookId));
await this.seriesMemberships?.syncPrimaryFromMetadata(bookId);
if (meta.authors.length > 0) {
await this.metadataService.replaceAuthors(
@@ -145,8 +145,15 @@ export class BookMetadataLockService {
this.copyResolvedField(filteredResolved, resolved, 'publishedYear', 'publishedYear', lockedSet, skippedFields);
this.copyResolvedField(filteredResolved, resolved, 'language', 'language', lockedSet, skippedFields);
this.copyResolvedField(filteredResolved, resolved, 'pageCount', 'pageCount', lockedSet, skippedFields);
this.copyResolvedField(filteredResolved, resolved, 'seriesName', 'seriesName', lockedSet, skippedFields);
this.copyResolvedField(filteredResolved, resolved, 'seriesIndex', 'seriesIndex', lockedSet, skippedFields);
if (resolved.seriesName !== undefined || resolved.seriesIndex !== undefined) {
if (lockedSet.has('seriesName') || lockedSet.has('seriesIndex')) {
if (lockedSet.has('seriesName')) skippedFields.add('seriesName');
if (lockedSet.has('seriesIndex')) skippedFields.add('seriesIndex');
} else {
if (resolved.seriesName !== undefined) filteredResolved.seriesName = resolved.seriesName;
if (resolved.seriesIndex !== undefined) filteredResolved.seriesIndex = resolved.seriesIndex;
}
}
this.copyResolvedField(filteredResolved, resolved, 'genres', 'genres', lockedSet, skippedFields);
this.copyResolvedField(filteredResolved, resolved, 'narrators', 'narrators', lockedSet, skippedFields);
this.copyResolvedField(filteredResolved, resolved, 'duration', 'durationSeconds', lockedSet, skippedFields);
@@ -213,8 +220,17 @@ export class BookMetadataLockService {
this.copyUpdateField(filteredDto, dto, 'publishedYear', 'publishedYear', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'language', 'language', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'pageCount', 'pageCount', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'seriesName', 'seriesName', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'seriesIndex', 'seriesIndex', lockedSet, skippedFields);
if (dto.seriesMemberships !== undefined || dto.seriesName !== undefined || dto.seriesIndex !== undefined) {
if (lockedSet.has('seriesName') || lockedSet.has('seriesIndex')) {
if (lockedSet.has('seriesName')) skippedFields.add('seriesName');
if (lockedSet.has('seriesIndex')) skippedFields.add('seriesIndex');
} else if (dto.seriesMemberships !== undefined) {
filteredDto.seriesMemberships = dto.seriesMemberships;
} else {
if (dto.seriesName !== undefined) filteredDto.seriesName = dto.seriesName;
if (dto.seriesIndex !== undefined) filteredDto.seriesIndex = dto.seriesIndex;
}
}
this.copyUpdateField(filteredDto, dto, 'isbn10', 'isbn10', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'isbn13', 'isbn13', lockedSet, skippedFields);
this.copyUpdateField(filteredDto, dto, 'rating', 'rating', lockedSet, skippedFields);
@@ -341,6 +357,10 @@ export class BookMetadataLockService {
this.addFieldIfPresent(fields, dto, 'pageCount', 'pageCount');
this.addFieldIfPresent(fields, dto, 'seriesName', 'seriesName');
this.addFieldIfPresent(fields, dto, 'seriesIndex', 'seriesIndex');
if (dto.seriesMemberships !== undefined) {
fields.add('seriesName');
fields.add('seriesIndex');
}
this.addFieldIfPresent(fields, dto, 'isbn10', 'isbn10');
this.addFieldIfPresent(fields, dto, 'isbn13', 'isbn13');
this.addFieldIfPresent(fields, dto, 'rating', 'rating');
@@ -498,17 +498,18 @@ describe('field coverage smoke tests', () => {
});
describe('buildQuickSearch', () => {
it('produces an OR of title/series ilike and author/narrator exists subqueries', () => {
it('produces an OR of title/series ilike and author/series/narrator exists subqueries', () => {
const { builder } = makeBuilder();
const result = builder.buildQuickSearch('tolkien') as any;
expect(result).toMatchObject({ type: 'or' });
expect(result.clauses).toHaveLength(4);
expect(result.clauses).toHaveLength(5);
expect(result.clauses[0]).toMatchObject({ type: 'ilike', pattern: '%tolkien%' });
expect(result.clauses[1]).toMatchObject({ type: 'sql' });
expect(result.clauses[2]).toMatchObject({ type: 'ilike', pattern: '%tolkien%' });
expect(result.clauses[3]).toMatchObject({ type: 'sql' });
expect(result.clauses[4]).toMatchObject({ type: 'sql' });
});
it('escapes LIKE special characters in q', () => {
@@ -528,12 +529,12 @@ describe('buildQuickSearch', () => {
expect(result.clauses[0]).toMatchObject({ type: 'ilike', pattern: '%book\\_one%' });
});
it('calls db.select twice (once for authors, once for narrators)', () => {
it('calls db.select three times for author, series membership, and narrator exists subqueries', () => {
const { builder, db } = makeBuilder();
builder.buildQuickSearch('dune');
expect(db.select).toHaveBeenCalledTimes(2);
expect(db.select).toHaveBeenCalledTimes(3);
});
});
@@ -16,6 +16,8 @@ import {
bookGenres,
bookMetadata,
bookNarrators,
bookSeries,
bookSeriesMemberships,
books,
bookTags,
collectionBooks,
@@ -96,7 +98,16 @@ export class BookQueryBuilder {
return sql`exists (${sq})`;
})();
return or(ilike(bookMetadata.title, pattern), existsAuthor, ilike(bookMetadata.seriesName, pattern), existsNarrator)!;
const existsSeries = (() => {
const sq = this.db
.select({ one: sql`1` })
.from(bookSeriesMemberships)
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
.where(and(eq(bookSeriesMemberships.bookId, books.id), ilike(bookSeries.name, pattern))!);
return sql`exists (${sq})`;
})();
return or(ilike(bookMetadata.title, pattern), existsAuthor, ilike(bookMetadata.seriesName, pattern), existsSeries, existsNarrator)!;
}
buildOrderBy(sort: SortSpec[], userId?: number): SQL[] {
@@ -125,13 +136,11 @@ export class BookQueryBuilder {
? this.textSetRuleToSql(bookMetadata.language, operator, value as string[])
: this.textRuleToSql(bookMetadata.language, operator, value as string);
case 'series':
return Array.isArray(value)
? this.textSetRuleToSql(bookMetadata.seriesName, operator, value as string[])
: this.textRuleToSql(bookMetadata.seriesName, operator, value as string);
return this.seriesRuleToSql(operator, value as string | string[] | undefined);
case 'publishedYear':
return this.numericRuleToSql(bookMetadata.publishedYear, operator, value as number, valueTo as number | undefined);
case 'seriesIndex':
return this.numericRuleToSql(bookMetadata.seriesIndex, operator, value as number, valueTo as number | undefined);
return this.seriesIndexRuleToSql(operator, value as number, valueTo as number | undefined);
case 'pageCount':
return this.numericRuleToSql(bookMetadata.pageCount, operator, value as number, valueTo as number | undefined);
case 'rating':
@@ -250,6 +259,101 @@ export class BookQueryBuilder {
}
}
private seriesRuleToSql(operator: string, value?: string | string[]): SQL {
const existsSeries = (whereClause?: SQL) => {
const predicates: SQL[] = [eq(bookSeriesMemberships.bookId, books.id)];
if (whereClause) predicates.push(whereClause);
const sq = this.db
.select({ one: sql`1` })
.from(bookSeriesMemberships)
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
.where(and(...predicates)!);
return sql`exists (${sq})`;
};
if (Array.isArray(value)) {
switch (operator) {
case 'includesAny':
if (!value.length) return sql`1 = 0`;
return existsSeries(inArray(bookSeries.name, value));
case 'excludesAll':
if (!value.length) return sql`1 = 1`;
return not(existsSeries(inArray(bookSeries.name, value)));
default:
throw new BadRequestException(`Invalid operator '${operator}' for series field`);
}
}
const VALUE_REQUIRED = ['contains', 'notContains', 'startsWith', 'endsWith', 'eq', 'notEq'];
if (VALUE_REQUIRED.includes(operator) && !value) {
throw new BadRequestException(`Operator '${operator}' requires a non-empty value`);
}
switch (operator) {
case 'contains':
return existsSeries(ilike(bookSeries.name, `%${escapeLikePattern(value!)}%`));
case 'notContains':
return not(existsSeries(ilike(bookSeries.name, `%${escapeLikePattern(value!)}%`)));
case 'startsWith':
return existsSeries(ilike(bookSeries.name, `${escapeLikePattern(value!)}%`));
case 'endsWith':
return existsSeries(ilike(bookSeries.name, `%${escapeLikePattern(value!)}`));
case 'eq':
return existsSeries(ilike(bookSeries.name, escapeLikePattern(value!)));
case 'notEq':
return not(existsSeries(ilike(bookSeries.name, escapeLikePattern(value!))));
case 'isEmpty':
return not(existsSeries());
case 'isNotEmpty':
return existsSeries();
default:
throw new BadRequestException(`Invalid operator '${operator}' for series field`);
}
}
private seriesIndexRuleToSql(operator: string, value?: number, valueTo?: number): SQL {
const existsSeriesIndex = (whereClause?: SQL) => {
const predicates: SQL[] = [eq(bookSeriesMemberships.bookId, books.id)];
if (whereClause) predicates.push(whereClause);
const sq = this.db
.select({ one: sql`1` })
.from(bookSeriesMemberships)
.where(and(...predicates)!);
return sql`exists (${sq})`;
};
switch (operator) {
case 'eq':
this.assertNumber(value, operator, 'value');
return existsSeriesIndex(eq(bookSeriesMemberships.seriesIndex, value!));
case 'notEq':
this.assertNumber(value, operator, 'value');
return existsSeriesIndex(ne(bookSeriesMemberships.seriesIndex, value!));
case 'gt':
this.assertNumber(value, operator, 'value');
return existsSeriesIndex(gt(bookSeriesMemberships.seriesIndex, value!));
case 'gte':
this.assertNumber(value, operator, 'value');
return existsSeriesIndex(gte(bookSeriesMemberships.seriesIndex, value!));
case 'lt':
this.assertNumber(value, operator, 'value');
return existsSeriesIndex(lt(bookSeriesMemberships.seriesIndex, value!));
case 'lte':
this.assertNumber(value, operator, 'value');
return existsSeriesIndex(lte(bookSeriesMemberships.seriesIndex, value!));
case 'between':
this.assertNumber(value, operator, 'value');
this.assertNumber(valueTo, operator, 'valueTo');
return existsSeriesIndex(and(gte(bookSeriesMemberships.seriesIndex, value!), lte(bookSeriesMemberships.seriesIndex, valueTo!))!);
case 'isEmpty':
return not(existsSeriesIndex(isNotNull(bookSeriesMemberships.seriesIndex)));
case 'isNotEmpty':
return existsSeriesIndex(isNotNull(bookSeriesMemberships.seriesIndex));
default:
throw new BadRequestException(`Invalid operator '${operator}' for numeric field`);
}
}
private ratingRuleToSql(operator: string, value: number | undefined, valueTo: number | undefined, userId: number): SQL {
const ratingExpr = sql<number>`(SELECT ${userBookRatings.rating} FROM ${userBookRatings} WHERE ${userBookRatings.bookId} = ${books.id} AND ${userBookRatings.userId} = ${userId})`;
switch (operator) {
@@ -64,6 +64,7 @@ describe('BookRepository', () => {
const fileProgressRows = [{ bookFileId: 1001, percentage: 45, updatedAt: new Date('2026-01-01T00:00:00.000Z') }];
const statusRows = [{ bookId: 10, status: 'reading', source: 'manual', startedAt: null, finishedAt: null, updatedAt: null }];
const narratorRows = [{ bookId: 10, name: 'Scott Brick' }];
const seriesMembershipRows = [{ bookId: 10, seriesId: 20, seriesName: 'Dune', seriesIndex: 1, displayOrder: 0 }];
const progressRows = [{ bookFileId: 1001, percentage: 45 }];
const db = {
@@ -75,6 +76,7 @@ describe('BookRepository', () => {
.mockReturnValueOnce(makeSelectChain('where', genreRows))
.mockReturnValueOnce(makeSelectChain('where', tagRows))
.mockReturnValueOnce(makeSelectChain('orderBy', narratorRows))
.mockReturnValueOnce(makeSelectChain('orderBy', seriesMembershipRows))
.mockReturnValueOnce(makeSelectChain('where', statusRows))
.mockReturnValueOnce(makeSelectChain('where', fileProgressRows))
.mockReturnValueOnce(makeSelectChain('where', [])),
@@ -92,6 +94,7 @@ describe('BookRepository', () => {
progressRows,
statusRows,
narratorRows,
seriesMembershipRows,
total: 1,
});
});
@@ -110,6 +113,7 @@ describe('BookRepository', () => {
.mockReturnValueOnce(makeSelectChain('where', []))
.mockReturnValueOnce(makeSelectChain('where', []))
.mockReturnValueOnce(makeSelectChain('orderBy', []))
.mockReturnValueOnce(makeSelectChain('orderBy', []))
.mockReturnValueOnce(makeSelectChain('where', []))
.mockReturnValueOnce(makeSelectChain('where', readingProgressRows))
.mockReturnValueOnce(makeSelectChain('where', audiobookProgressRows)),
@@ -134,6 +138,7 @@ describe('BookRepository', () => {
progressRows: [],
statusRows: [],
narratorRows: [],
seriesMembershipRows: [],
total: 0,
});
expect(db.select).not.toHaveBeenCalled();
@@ -172,6 +177,7 @@ describe('BookRepository', () => {
{ id: 99, format: 'epub', role: 'primary', sizeBytes: 1, absolutePath: '/books/dune.epub', createdAt: new Date(), durationSeconds: null },
];
const narratorRows = [{ id: 4, name: 'Narrator', sortName: null, displayOrder: 0 }];
const seriesMembershipRows = [{ seriesId: 20, seriesName: 'Dune', seriesIndex: 1, displayOrder: 0 }];
const db = {
select: vi
.fn()
@@ -180,7 +186,8 @@ describe('BookRepository', () => {
.mockReturnValueOnce(makeSelectChain('where', genreRows))
.mockReturnValueOnce(makeSelectChain('where', tagRows))
.mockReturnValueOnce(makeSelectChain('orderBy', fileRows))
.mockReturnValueOnce(makeSelectChain('orderBy', narratorRows)),
.mockReturnValueOnce(makeSelectChain('orderBy', narratorRows))
.mockReturnValueOnce(makeSelectChain('orderBy', seriesMembershipRows)),
};
const repo = new BookRepository(db as never);
@@ -193,6 +200,7 @@ describe('BookRepository', () => {
tagRows,
fileRows,
narratorRows,
seriesMembershipRows,
});
});
+122 -62
View File
@@ -7,6 +7,7 @@ import type { ContentFilterRules, JumpBucketsResponse, SortSpec } from '@bookorb
import { isAudioFormat, isComicFormat } from '@bookorbit/types';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import { SeriesIdentityService } from '../../common/services/series-identity.service';
import { SeriesMembershipService } from '../../common/services/series-membership.service';
import { BookQueryBuilder } from './book-query-builder.service';
import { DB } from '../../db';
import * as schema from '../../db/schema';
@@ -17,6 +18,8 @@ import {
bookGenres,
bookMetadata,
bookNarrators,
bookSeries,
bookSeriesMemberships,
books,
bookTags,
collectionBooks,
@@ -37,7 +40,7 @@ import {
type Db = NodePgDatabase<typeof schema>;
type DbTransaction = Parameters<Parameters<Db['transaction']>[0]>[0];
type MetadataUpdateExecutor = Pick<Db, 'update' | 'insert'>;
type MetadataUpdateExecutor = Pick<Db, 'delete' | 'insert' | 'select' | 'update'>;
type MetadataReadExecutor = Pick<Db, 'select'>;
type JsonObj = Record<string, unknown>;
@@ -108,6 +111,7 @@ export class BookRepository {
constructor(
@Inject(DB) private readonly db: Db,
@Optional() private readonly seriesIdentity?: SeriesIdentityService,
@Optional() private readonly seriesMemberships?: SeriesMembershipService,
) {}
private visibleWhere(where: SQL | undefined): SQL {
@@ -182,66 +186,86 @@ export class BookRepository {
updatedAt: Date;
}[],
narratorRows: [] as { bookId: number; name: string }[],
seriesMembershipRows: [] as {
bookId: number;
seriesId: number;
seriesName: string;
seriesIndex: number | null;
displayOrder: number;
}[],
};
}
const [authorRows, fileRows, genreRows, tagRows, narratorRows, statusRows, fileProgressRows, audiobookProgressRows] = await Promise.all([
this.db
.select({ bookId: bookAuthors.bookId, name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(inArray(bookAuthors.bookId, bookIds))
.orderBy(bookAuthors.displayOrder),
this.db
.select({ bookId: bookFiles.bookId, id: bookFiles.id, format: bookFiles.format, role: bookFiles.role, sizeBytes: bookFiles.sizeBytes })
.from(bookFiles)
.where(inArray(bookFiles.bookId, bookIds)),
this.db
.select({ bookId: bookGenres.bookId, name: genres.name })
.from(bookGenres)
.innerJoin(genres, eq(genres.id, bookGenres.genreId))
.where(inArray(bookGenres.bookId, bookIds)),
this.db
.select({ bookId: bookTags.bookId, name: tags.name })
.from(bookTags)
.innerJoin(tags, eq(tags.id, bookTags.tagId))
.where(inArray(bookTags.bookId, bookIds)),
this.db
.select({ bookId: bookNarrators.bookId, name: narrators.name })
.from(bookNarrators)
.innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId))
.where(inArray(bookNarrators.bookId, bookIds))
.orderBy(bookNarrators.displayOrder),
this.db
.select({
bookId: userBookStatus.bookId,
status: userBookStatus.status,
source: userBookStatus.source,
startedAt: userBookStatus.startedAt,
finishedAt: userBookStatus.finishedAt,
updatedAt: userBookStatus.updatedAt,
})
.from(userBookStatus)
.where(and(eq(userBookStatus.userId, userId), inArray(userBookStatus.bookId, bookIds))),
primaryFileIds.length > 0
? this.db
.select({
bookFileId: readingProgress.bookFileId,
percentage: readingProgress.percentage,
updatedAt: readingProgress.updatedAt,
})
.from(readingProgress)
.where(and(eq(readingProgress.userId, userId), inArray(readingProgress.bookFileId, primaryFileIds)))
: Promise.resolve([] as { bookFileId: number; percentage: number; updatedAt: Date }[]),
this.db
.select({
bookId: audiobookProgress.bookId,
percentage: audiobookProgress.percentage,
updatedAt: audiobookProgress.updatedAt,
})
.from(audiobookProgress)
.where(and(eq(audiobookProgress.userId, userId), inArray(audiobookProgress.bookId, bookIds))),
]);
const [authorRows, fileRows, genreRows, tagRows, narratorRows, seriesMembershipRows, statusRows, fileProgressRows, audiobookProgressRows] =
await Promise.all([
this.db
.select({ bookId: bookAuthors.bookId, name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(inArray(bookAuthors.bookId, bookIds))
.orderBy(bookAuthors.displayOrder),
this.db
.select({ bookId: bookFiles.bookId, id: bookFiles.id, format: bookFiles.format, role: bookFiles.role, sizeBytes: bookFiles.sizeBytes })
.from(bookFiles)
.where(inArray(bookFiles.bookId, bookIds)),
this.db
.select({ bookId: bookGenres.bookId, name: genres.name })
.from(bookGenres)
.innerJoin(genres, eq(genres.id, bookGenres.genreId))
.where(inArray(bookGenres.bookId, bookIds)),
this.db
.select({ bookId: bookTags.bookId, name: tags.name })
.from(bookTags)
.innerJoin(tags, eq(tags.id, bookTags.tagId))
.where(inArray(bookTags.bookId, bookIds)),
this.db
.select({ bookId: bookNarrators.bookId, name: narrators.name })
.from(bookNarrators)
.innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId))
.where(inArray(bookNarrators.bookId, bookIds))
.orderBy(bookNarrators.displayOrder),
this.db
.select({
bookId: bookSeriesMemberships.bookId,
seriesId: bookSeriesMemberships.seriesId,
seriesName: bookSeries.name,
seriesIndex: bookSeriesMemberships.seriesIndex,
displayOrder: bookSeriesMemberships.displayOrder,
})
.from(bookSeriesMemberships)
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
.where(inArray(bookSeriesMemberships.bookId, bookIds))
.orderBy(asc(bookSeriesMemberships.bookId), asc(bookSeriesMemberships.displayOrder), asc(bookSeriesMemberships.seriesId)),
this.db
.select({
bookId: userBookStatus.bookId,
status: userBookStatus.status,
source: userBookStatus.source,
startedAt: userBookStatus.startedAt,
finishedAt: userBookStatus.finishedAt,
updatedAt: userBookStatus.updatedAt,
})
.from(userBookStatus)
.where(and(eq(userBookStatus.userId, userId), inArray(userBookStatus.bookId, bookIds))),
primaryFileIds.length > 0
? this.db
.select({
bookFileId: readingProgress.bookFileId,
percentage: readingProgress.percentage,
updatedAt: readingProgress.updatedAt,
})
.from(readingProgress)
.where(and(eq(readingProgress.userId, userId), inArray(readingProgress.bookFileId, primaryFileIds)))
: Promise.resolve([] as { bookFileId: number; percentage: number; updatedAt: Date }[]),
this.db
.select({
bookId: audiobookProgress.bookId,
percentage: audiobookProgress.percentage,
updatedAt: audiobookProgress.updatedAt,
})
.from(audiobookProgress)
.where(and(eq(audiobookProgress.userId, userId), inArray(audiobookProgress.bookId, bookIds))),
]);
const fileProgressById = new Map(fileProgressRows.map((row) => [row.bookFileId, row]));
const audiobookProgressByBookId = new Map(audiobookProgressRows.map((row) => [row.bookId, row]));
@@ -262,7 +286,7 @@ export class BookRepository {
return [{ bookFileId: book.primaryFileId, percentage: mergedPercentage }];
});
return { authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows };
return { authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows, seriesMembershipRows };
}
async findCardsByBookIds(bookIds: number[], userId: number) {
@@ -276,6 +300,7 @@ export class BookRepository {
progressRows: [],
statusRows: [],
narratorRows: [],
seriesMembershipRows: [],
total: 0,
};
}
@@ -333,6 +358,7 @@ export class BookRepository {
updatedAt: Date;
}[];
narratorRows: { bookId: number; name: string }[];
seriesMembershipRows: { bookId: number; seriesId: number; seriesName: string; seriesIndex: number | null; displayOrder: number }[];
total: number;
}> {
const { where, sort, limit, offset, userId } = opts;
@@ -656,7 +682,7 @@ export class BookRepository {
if (!book) return null;
const [authorRows, genreRows, tagRows, fileRows, narratorRows] = await Promise.all([
const [authorRows, genreRows, tagRows, fileRows, narratorRows, seriesMembershipRows] = await Promise.all([
this.db
.select({ id: authors.id, name: authors.name, sortName: authors.sortName })
.from(bookAuthors)
@@ -684,9 +710,20 @@ export class BookRepository {
.innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId))
.where(eq(bookNarrators.bookId, id))
.orderBy(bookNarrators.displayOrder),
this.db
.select({
seriesId: bookSeriesMemberships.seriesId,
seriesName: bookSeries.name,
seriesIndex: bookSeriesMemberships.seriesIndex,
displayOrder: bookSeriesMemberships.displayOrder,
})
.from(bookSeriesMemberships)
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
.where(eq(bookSeriesMemberships.bookId, id))
.orderBy(asc(bookSeriesMemberships.displayOrder), asc(bookSeriesMemberships.seriesId)),
]);
return { book, authorRows, genreRows, tagRows, fileRows, narratorRows };
return { book, authorRows, genreRows, tagRows, fileRows, narratorRows, seriesMembershipRows };
}
async findRatingByBookAndUser(bookId: number, userId: number): Promise<number | null> {
@@ -860,6 +897,13 @@ export class BookRepository {
.where(sql`${authors.name} ILIKE ${pattern}`)
.as('matched_authors');
const matchedSeries = this.db
.selectDistinct({ bookId: bookSeriesMemberships.bookId })
.from(bookSeriesMemberships)
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
.where(sql`${bookSeries.name} ILIKE ${pattern}`)
.as('matched_series');
const contentFilterClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
const rows = await this.db
@@ -875,11 +919,17 @@ export class BookRepository {
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.leftJoin(matchedAuthors, eq(matchedAuthors.bookId, books.id))
.leftJoin(matchedSeries, eq(matchedSeries.bookId, books.id))
.where(
and(
inArray(books.libraryId, libraryIds),
ne(books.status, 'processing'),
or(sql`${bookMetadata.title} ILIKE ${pattern}`, sql`${bookMetadata.seriesName} ILIKE ${pattern}`, isNotNull(matchedAuthors.bookId)),
or(
sql`${bookMetadata.title} ILIKE ${pattern}`,
sql`${bookMetadata.seriesName} ILIKE ${pattern}`,
isNotNull(matchedAuthors.bookId),
isNotNull(matchedSeries.bookId),
),
...contentFilterClauses,
),
)
@@ -1161,8 +1211,13 @@ export class BookRepository {
fields: Partial<typeof bookMetadata.$inferInsert>,
executor: MetadataUpdateExecutor = this.db,
): Promise<void> {
const shouldSyncSeries =
Object.prototype.hasOwnProperty.call(fields, 'seriesName') || Object.prototype.hasOwnProperty.call(fields, 'seriesIndex');
const patch = (await this.seriesIdentity?.resolveMetadataPatch(fields, executor)) ?? fields;
await executor.update(bookMetadata).set(patch).where(eq(bookMetadata.bookId, bookId));
if (shouldSyncSeries) {
await this.seriesMemberships?.syncPrimaryFromMetadata(bookId, executor);
}
await executor.update(books).set({ updatedAt: new Date() }).where(eq(books.id, bookId));
}
@@ -1172,8 +1227,13 @@ export class BookRepository {
executor: MetadataUpdateExecutor = this.db,
): Promise<void> {
if (bookIds.length === 0) return;
const shouldSyncSeries =
Object.prototype.hasOwnProperty.call(fields, 'seriesName') || Object.prototype.hasOwnProperty.call(fields, 'seriesIndex');
const patch = (await this.seriesIdentity?.resolveMetadataPatch(fields, executor)) ?? fields;
await executor.update(bookMetadata).set(patch).where(inArray(bookMetadata.bookId, bookIds));
if (shouldSyncSeries) {
await this.seriesMemberships?.syncPrimaryFromMetadataForBooks(bookIds, executor);
}
await executor.update(books).set({ updatedAt: new Date() }).where(inArray(books.id, bookIds));
}
+53 -14
View File
@@ -17,6 +17,7 @@ import { bookCoverDirPath, bookThumbnailPath, findPreferredBookCoverFileName } f
import { MAX_BOOK_QUERY_OFFSET_ROWS, isBookQueryOffsetWithinLimit } from '../../common/constants/pagination.constants';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { formatSeriesIndex } from '../../common/utils/series-index-format.utils';
import { SeriesMembershipService } from '../../common/services/series-membership.service';
import { isDateKey, resolveTimeZone, toDateKeyInTimeZone, toTimeZoneStartOfDay } from '../../common/utils/timezone.utils';
import { extractEpubMetadata } from '../metadata/lib/epub';
import { extractAudioMetadata } from '../metadata/extractors/audio.extractor';
@@ -89,6 +90,7 @@ import type { SetStatusDto } from '../user-book-status/dto/set-status.dto';
const METADATA_UPDATE_FAILPOINTS = [
'afterScalarUpdate',
'afterSeriesMembershipsReplace',
'afterComicMetadataUpsert',
'afterAuthorsReplace',
'afterNarratorsReplace',
@@ -234,6 +236,7 @@ export class BookService {
@Optional() private readonly fileWriteService: FileWriteService,
@Optional() private readonly fileRenameService: FileRenameService,
@Optional() private readonly achievementEvents: AchievementEventsService,
@Optional() private readonly seriesMemberships?: SeriesMembershipService,
) {
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
}
@@ -945,7 +948,7 @@ export class BookService {
const shouldCollapse = query.collapseSeries === true && !BookQueryBuilder.hasSeriesFilter(query.filter);
if (shouldCollapse) {
const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows, total } =
const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows, seriesMembershipRows, total } =
await this.bookRepo.findCardsCollapsed({
where,
sort: query.sort,
@@ -954,7 +957,17 @@ export class BookService {
userId,
});
const result = {
items: assembleCollapsedBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows, narratorRows, tagRows),
items: assembleCollapsedBookCards(
rows,
authorRows,
fileRows,
genreRows,
progressRows,
statusRows,
narratorRows,
tagRows,
seriesMembershipRows,
),
total,
page,
size,
@@ -969,15 +982,16 @@ export class BookService {
}
const orderBy = this.queryBuilder.buildOrderBy(query.sort, userId);
const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows, total } = await this.bookRepo.findCards({
where,
orderBy,
limit: size,
offset: page * size,
userId,
});
const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows, seriesMembershipRows, total } =
await this.bookRepo.findCards({
where,
orderBy,
limit: size,
offset: page * size,
userId,
});
const result = {
items: assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows, narratorRows, tagRows),
items: assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows, narratorRows, tagRows, seriesMembershipRows),
total,
page,
size,
@@ -1416,8 +1430,10 @@ export class BookService {
if (dto.publishedYear !== undefined) scalarFields.publishedYear = dto.publishedYear ?? null;
if (dto.language !== undefined) scalarFields.language = dto.language ?? null;
if (dto.pageCount !== undefined) scalarFields.pageCount = dto.pageCount ?? null;
if (dto.seriesName !== undefined) scalarFields.seriesName = dto.seriesName ?? null;
if (dto.seriesIndex !== undefined) scalarFields.seriesIndex = dto.seriesIndex ?? null;
if (dto.seriesMemberships === undefined) {
if (dto.seriesName !== undefined) scalarFields.seriesName = dto.seriesName ?? null;
if (dto.seriesIndex !== undefined) scalarFields.seriesIndex = dto.seriesIndex ?? null;
}
if (dto.isbn10 !== undefined) scalarFields.isbn10 = dto.isbn10 ?? null;
if (dto.isbn13 !== undefined) scalarFields.isbn13 = dto.isbn13 ?? null;
if (dto.googleBooksId !== undefined) scalarFields.googleBooksId = dto.googleBooksId ?? null;
@@ -1452,6 +1468,11 @@ export class BookService {
}
this.throwIfMetadataUpdateFailpoint('afterScalarUpdate');
if (dto.seriesMemberships !== undefined) {
await this.seriesMemberships?.replaceForBook(id, dto.seriesMemberships, tx);
}
this.throwIfMetadataUpdateFailpoint('afterSeriesMembershipsReplace');
if (dto.comicMetadata) {
await this.comicMetadataService.upsert(id, dto.comicMetadata, tx);
}
@@ -1504,7 +1525,9 @@ export class BookService {
if (hasMetadataUpdate) {
this.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Embedding failed for book ${id}: ${err.message}`));
const hasRenameRelevantField = Array.from(RENAME_RELEVANT_FIELDS).some((field) => (dto as Record<string, unknown>)[field] !== undefined);
const hasRenameRelevantField =
dto.seriesMemberships !== undefined ||
Array.from(RENAME_RELEVANT_FIELDS).some((field) => (dto as Record<string, unknown>)[field] !== undefined);
const postSaveMode = options.postSaveMode ?? 'schedule';
if (postSaveMode === 'sync') {
const settingsResult = await this.findLibraryWriteSettingsAfterSave(id);
@@ -2038,6 +2061,13 @@ export class BookService {
});
};
const getSeriesUpdatableIds = (): number[] => {
return bookIds.filter((id) => {
const locked = locksMap.get(id) ?? [];
return !locked.includes('seriesName') && !locked.includes('seriesIndex');
});
};
const recordResult = (fieldName: string, updatableIds: number[]) => {
const skippedLocked = bookIds.length - updatableIds.length;
fieldResults[fieldName] = { updated: updatableIds.length, skippedLocked };
@@ -2056,6 +2086,14 @@ export class BookService {
await this.bookRepo.bulkUpdateMetadataFields(ids, { [fieldName]: textValue, updatedAt: new Date() }, tx);
}
if (fields.seriesMemberships !== undefined) {
const ids = getSeriesUpdatableIds();
recordResult('seriesMemberships', ids);
for (const bookId of ids) {
await this.seriesMemberships?.replaceForBook(bookId, fields.seriesMemberships, tx);
}
}
if (fields.publishedYear) {
const ids = getUpdatableIds('publishedYear');
recordResult('publishedYear', ids);
@@ -2599,7 +2637,7 @@ export class BookService {
]);
if (!result) throw new NotFoundException(`Book ${id} not found`);
const { book, authorRows, genreRows, tagRows, fileRows, narratorRows } = result;
const { book, authorRows, genreRows, tagRows, fileRows, narratorRows, seriesMembershipRows } = result;
const meta = book.book_metadata;
const hasAudioFiles = fileRows.some((f) => f.format && isAudioFormat(f.format));
const resolvedChapters = this.resolveChapters(meta?.chapters as AudiobookChapter[] | null | undefined, fileRows);
@@ -2635,6 +2673,7 @@ export class BookService {
seriesId: meta?.seriesId ?? null,
seriesName: meta?.seriesName ?? null,
seriesIndex: meta?.seriesIndex ?? null,
seriesMemberships: seriesMembershipRows,
rating: personalRating,
coverSource: (meta?.coverSource as 'extracted' | 'custom' | null) ?? null,
lockedFields: this.bookMetadataLockService.normalizeLockedFields(meta?.lockedFields),
@@ -1,6 +1,7 @@
import type {
AudiobookChapter,
BookFileWriteStatus,
BookSeriesMembership,
BookMetadataLockField,
ComicMetadataFields,
NarratorRef,
@@ -45,6 +46,7 @@ export class BookDetailDto {
seriesId: number | null;
seriesName: string | null;
seriesIndex: number | null;
seriesMemberships: BookSeriesMembership[];
rating: number | null;
coverSource: 'extracted' | 'custom' | null;
providerIds: ProviderIds;
@@ -1,6 +1,7 @@
import { IsArray, IsIn, IsInt, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { BulkSelectionDto } from './bulk-selection.dto';
import { BookSeriesMembershipDto } from './update-book-metadata.dto';
const ARRAY_MODES = ['add', 'remove', 'replace'] as const;
export type BulkArrayMode = (typeof ARRAY_MODES)[number];
@@ -26,7 +27,17 @@ export class BulkScalarNumberFieldDto {
value!: number | null;
}
const ALLOWED_FIELD_KEYS = new Set<string>(['authors', 'seriesName', 'genres', 'tags', 'publisher', 'language', 'publishedYear', 'narrators']);
const ALLOWED_FIELD_KEYS = new Set<string>([
'authors',
'seriesName',
'seriesMemberships',
'genres',
'tags',
'publisher',
'language',
'publishedYear',
'narrators',
]);
export class BulkEditFieldsDto {
@IsOptional()
@@ -39,6 +50,12 @@ export class BulkEditFieldsDto {
@Type(() => BulkScalarStringFieldDto)
seriesName?: BulkScalarStringFieldDto;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => BookSeriesMembershipDto)
seriesMemberships?: BookSeriesMembershipDto[];
@IsOptional()
@ValidateNested()
@Type(() => BulkArrayFieldDto)
@@ -28,6 +28,11 @@ export class ComicMetadataDto {
@IsOptional() @IsArray() @IsString({ each: true }) storyArcs?: string[];
}
export class BookSeriesMembershipDto {
@IsString() @MaxLength(500) seriesName!: string;
@IsOptional() @IsNumber() seriesIndex?: number | null;
}
export class UpdateBookMetadataDto {
@IsOptional() @IsString() @MaxLength(1000) title?: string | null;
@IsOptional() @IsString() @MaxLength(1000) subtitle?: string | null;
@@ -39,6 +44,7 @@ export class UpdateBookMetadataDto {
@IsOptional() @Transform(({ value }) => (value === 0 ? null : value)) @IsInt() @Min(1) pageCount?: number | null;
@IsOptional() @IsString() @MaxLength(500) seriesName?: string | null;
@IsOptional() @IsNumber() seriesIndex?: number | null;
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => BookSeriesMembershipDto) seriesMemberships?: BookSeriesMembershipDto[] | null;
@IsOptional() @IsString() @MaxLength(10) isbn10?: string | null;
@IsOptional() @IsString() @MaxLength(13) isbn13?: string | null;
@IsOptional() @IsInt() @Min(1) @Max(5) rating?: number | null;
@@ -46,6 +46,7 @@ type CollapsedBookRow = BookRow & {
type NameRow = { bookId: number; name: string };
type NarratorRow = { bookId: number; name: string };
type FileRow = { bookId: number; id: number; format: string | null; role: string; sizeBytes: number | null };
type SeriesMembershipRow = { bookId: number; seriesId: number; seriesName: string; seriesIndex: number | null; displayOrder: number };
type ProgressRow = { bookFileId: number; percentage: number | null };
type StatusRow = {
bookId: number;
@@ -65,6 +66,7 @@ export function assembleBookCards(
statusRows: StatusRow[] = [],
narratorRows: NarratorRow[] = [],
tagRows: NameRow[] = [],
seriesMembershipRows: SeriesMembershipRow[] = [],
): BookCard[] {
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
@@ -117,6 +119,13 @@ export function assembleBookCards(
tagsByBook.set(row.bookId, list);
}
const seriesMembershipsByBook = new Map<number, SeriesMembershipRow[]>();
for (const row of seriesMembershipRows) {
const list = seriesMembershipsByBook.get(row.bookId) ?? [];
list.push(row);
seriesMembershipsByBook.set(row.bookId, list);
}
return rows.map((row) => {
const rawFiles = filesByBook.get(row.id) ?? [];
const primaryFile =
@@ -140,6 +149,15 @@ export function assembleBookCards(
seriesId: row.seriesId ?? null,
seriesName: row.seriesName ?? null,
seriesIndex: row.seriesIndex ?? null,
seriesMemberships: (seriesMembershipsByBook.get(row.id) ?? [])
.slice()
.sort((a, b) => a.displayOrder - b.displayOrder || a.seriesId - b.seriesId)
.map((membership) => ({
seriesId: membership.seriesId,
seriesName: membership.seriesName,
seriesIndex: membership.seriesIndex,
displayOrder: membership.displayOrder,
})),
authors: authorsByBook.get(row.id) ?? [],
files,
publishedYear: row.publishedYear ?? null,
@@ -173,8 +191,9 @@ export function assembleCollapsedBookCards(
statusRows: StatusRow[] = [],
narratorRows: NarratorRow[] = [],
tagRows: NameRow[] = [],
seriesMembershipRows: SeriesMembershipRow[] = [],
): BookCard[] {
const base = assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows, narratorRows, tagRows);
const base = assembleBookCards(rows, authorRows, fileRows, genreRows, progressRows, statusRows, narratorRows, tagRows, seriesMembershipRows);
for (let i = 0; i < base.length; i++) {
const row = rows[i];
@@ -8,7 +8,7 @@ vi.mock('drizzle-orm', () => ({
import { and, eq, ilike, isNotNull } from 'drizzle-orm';
import { authors, bookMetadata, collections, narrators } from '../../db/schema';
import { authors, bookMetadata, bookSeries, collections, narrators } from '../../db/schema';
import { CatalogService } from './catalog.service';
interface QueryChain<T> {
@@ -122,13 +122,14 @@ describe('CatalogService', () => {
});
it('queries the expected metadata column for series search', async () => {
const { service, setSelectDistinctRows } = makeService();
setSelectDistinctRows([{ name: 'The Expanse' }]);
const { service, setSelectRows, selectChains } = makeService();
setSelectRows([{ name: 'The Expanse' }]);
await service.searchSeries('Expanse');
expect(isNotNull).toHaveBeenCalledWith(bookMetadata.seriesName);
expect(ilike).toHaveBeenCalledWith(bookMetadata.seriesName, '%Expanse%');
expect(ilike).toHaveBeenCalledWith(bookSeries.name, '%Expanse%');
expect(selectChains[0]?.from).toHaveBeenCalledWith(bookSeries);
expect(selectChains[0]?.orderBy).toHaveBeenCalledWith(bookSeries.name);
});
it('queries the expected metadata column for language search', async () => {
@@ -4,14 +4,14 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookMetadata, collections, genres, narrators, tags } from '../../db/schema';
import { authors, bookMetadata, bookSeries, collections, genres, narrators, tags } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
type SearchResult = { name: string };
type SearchResultWithId = { id: number; name: string };
type NamedTable = typeof authors | typeof genres | typeof tags | typeof narrators;
type NamedTable = typeof authors | typeof genres | typeof tags | typeof narrators | typeof bookSeries;
type NamedTableWithId = typeof genres | typeof tags;
type MetadataTextColumn = typeof bookMetadata.publisher | typeof bookMetadata.seriesName | typeof bookMetadata.language;
type MetadataTextColumn = typeof bookMetadata.publisher | typeof bookMetadata.language;
const DEFAULT_SEARCH_LIMIT = 15;
const COLLECTION_SEARCH_LIMIT = 20;
@@ -42,7 +42,7 @@ export class CatalogService {
}
searchSeries(q: string): Promise<SearchResult[]> {
return this.searchDistinctMetadataField(q, bookMetadata.seriesName);
return this.searchByName(q, bookSeries);
}
searchLanguages(q: string): Promise<SearchResult[]> {
@@ -47,10 +47,12 @@ function makeUpdateTx() {
const where = vi.fn().mockResolvedValue(undefined);
const set = vi.fn().mockReturnValue({ where });
const update = vi.fn().mockReturnValue({ set });
const deleteWhere = vi.fn().mockResolvedValue(undefined);
const deleteFrom = vi.fn().mockReturnValue({ where: deleteWhere });
const execute = vi.fn().mockResolvedValue({ rows: [] });
const tx = { update, execute };
const tx = { update, execute, delete: deleteFrom };
const transaction = vi.fn().mockImplementation(async (callback: (txArg: typeof tx) => Promise<unknown>) => callback(tx));
return { transaction, tx, update, set, where, execute };
return { transaction, tx, update, set, where, deleteFrom, deleteWhere, execute };
}
describe('SeriesStrategy', () => {
@@ -159,8 +161,8 @@ describe('SeriesStrategy', () => {
expect(select).not.toHaveBeenCalled();
});
it('merges source series into a target for scoped books only', async () => {
const { transaction, set, where } = makeUpdateTx();
it('merges source series memberships into a target for scoped books only', async () => {
const { transaction, execute } = makeUpdateTx();
const strategy = makeStrategy({ transaction });
vi.spyOn(strategy, 'findEntityById').mockResolvedValue({ id: 1, name: 'Target' });
vi.spyOn(strategy as never, 'findAffectedBookIdsInLibraries').mockResolvedValue([10, 20]);
@@ -170,8 +172,9 @@ describe('SeriesStrategy', () => {
affectedBookIds: [10, 20],
});
expect(set).toHaveBeenCalledWith(expect.objectContaining({ seriesId: 1, seriesName: 'Target', updatedAt: expect.any(Date) }));
expect(where).toHaveBeenCalledTimes(1);
const statements = execute.mock.calls.map((call) => flattenSql(call[0]).replace(/\s+/g, ' '));
expect(statements.some((statement) => statement.includes('UPDATE book_series_memberships'))).toBe(true);
expect(statements.some((statement) => statement.includes('UPDATE book_metadata bm'))).toBe(true);
});
it('does not merge when target or source ids are invalid', async () => {
@@ -189,7 +192,7 @@ describe('SeriesStrategy', () => {
});
it('renames a series and reports implicit merges', async () => {
const { transaction, set } = makeUpdateTx();
const { transaction, execute } = makeUpdateTx();
const strategy = makeStrategy({ transaction });
vi.spyOn(strategy, 'findEntityById').mockResolvedValue({ id: 2, name: 'Old' });
vi.spyOn(strategy as never, 'upsertSeries').mockResolvedValue({ id: 5, name: 'New' });
@@ -199,7 +202,9 @@ describe('SeriesStrategy', () => {
const result = await strategy.rename({ entityId: 2, newName: ' New ', userId: 9, libraryIds: [1] });
expect(result).toEqual({ oldName: 'Old', affectedBookIds: [10], wasImplicitMerge: true, mergedEntityId: 5 });
expect(set).toHaveBeenCalledWith(expect.objectContaining({ seriesId: 5, seriesName: 'New', updatedAt: expect.any(Date) }));
const statements = execute.mock.calls.map((call) => flattenSql(call[0]).replace(/\s+/g, ' '));
expect(statements.some((statement) => statement.includes('UPDATE book_series_memberships'))).toBe(true);
expect(statements.some((statement) => statement.includes('UPDATE book_metadata bm'))).toBe(true);
});
it('renames a series without reporting a merge when the identity is unchanged', async () => {
@@ -239,8 +244,8 @@ describe('SeriesStrategy', () => {
await expect(strategy.rename({ entityId: 2, newName: ' ', userId: 9, libraryIds: [1] })).rejects.toBeInstanceOf(BadRequestException);
});
it('deletes a series by clearing metadata for scoped books', async () => {
const { transaction, set } = makeUpdateTx();
it('deletes a series by removing scoped memberships and syncing primary metadata', async () => {
const { transaction, deleteFrom, deleteWhere, execute } = makeUpdateTx();
const strategy = makeStrategy({ transaction });
vi.spyOn(strategy, 'findEntityById').mockResolvedValue({ id: 2, name: 'Old' });
vi.spyOn(strategy as never, 'findAffectedBookIdsInLibraries').mockResolvedValue([10, 11]);
@@ -249,7 +254,10 @@ describe('SeriesStrategy', () => {
const result = await strategy.deleteEntity({ entityId: 2, mode: 'hard', libraryIds: [1] });
expect(result).toEqual({ name: 'Old', affectedBookIds: [10, 11] });
expect(set).toHaveBeenCalledWith(expect.objectContaining({ seriesId: null, seriesName: null, updatedAt: expect.any(Date) }));
expect(deleteFrom).toHaveBeenCalledTimes(1);
expect(deleteWhere).toHaveBeenCalledTimes(1);
const statements = execute.mock.calls.map((call) => flattenSql(call[0]).replace(/\s+/g, ' '));
expect(statements.some((statement) => statement.includes('UPDATE book_metadata bm'))).toBe(true);
});
it('does not start a delete transaction when no scoped books are affected', async () => {
@@ -326,7 +334,8 @@ describe('SeriesStrategy', () => {
const limit = vi.fn().mockResolvedValue([{ title: 'A' }, { title: 'B' }]);
const orderBy = vi.fn().mockReturnValue({ limit });
const where = vi.fn().mockReturnValue({ orderBy });
const from = vi.fn().mockReturnValue({ where });
const innerJoin = vi.fn().mockReturnValue({ where });
const from = vi.fn().mockReturnValue({ innerJoin });
const select = vi.fn().mockReturnValue({ from });
const strategy = makeStrategy({ select });
@@ -1,11 +1,11 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { SQL, and, asc, count, desc, eq, ilike, inArray, isNotNull, sql } from 'drizzle-orm';
import { SQL, and, asc, count, desc, eq, ilike, inArray, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { buildContentFilterClauses } from '../../../common/utils/content-filter-sql.utils';
import { DB } from '../../../db';
import * as schema from '../../../db/schema';
import { bookMetadata, books, bookSeries } from '../../../db/schema';
import { bookMetadata, books, bookSeries, bookSeriesMemberships } from '../../../db/schema';
import type {
BrowseParams,
BrowseResult,
@@ -67,14 +67,14 @@ export class SeriesStrategy implements EntityStrategy {
JOIN book_series s2 ON s1.id < s2.id AND s1.name % s2.name
WHERE similarity(s1.name, s2.name) >= ${similarityThreshold}
AND EXISTS (
SELECT 1 FROM book_metadata bm
JOIN books b ON b.id = bm.book_id
WHERE bm.series_id = s1.id AND b.library_id IN (${libraryIdList})
SELECT 1 FROM book_series_memberships bsm
JOIN books b ON b.id = bsm.book_id
WHERE bsm.series_id = s1.id AND b.library_id IN (${libraryIdList})
)
AND EXISTS (
SELECT 1 FROM book_metadata bm
JOIN books b ON b.id = bm.book_id
WHERE bm.series_id = s2.id AND b.library_id IN (${libraryIdList})
SELECT 1 FROM book_series_memberships bsm
JOIN books b ON b.id = bsm.book_id
WHERE bsm.series_id = s2.id AND b.library_id IN (${libraryIdList})
)
ORDER BY similarity(s1.name, s2.name) DESC
`);
@@ -116,26 +116,26 @@ export class SeriesStrategy implements EntityStrategy {
if (params.libraryIds.length === 0) return { items: [], total: 0 };
const filterClauses = params.contentFilters ? buildContentFilterClauses(params.contentFilters, this.db) : [];
const conditions = [inArray(books.libraryId, params.libraryIds), isNotNull(bookMetadata.seriesId), ...filterClauses];
const conditions = [inArray(books.libraryId, params.libraryIds), ...filterClauses];
if (params.search) {
conditions.push(ilike(bookSeries.name, `%${escapeLike(params.search)}%`) as never);
}
const where = and(...conditions)!;
const bookCountExpr = sql<number>`count(distinct ${bookMetadata.bookId})::int`;
const bookCountExpr = sql<number>`count(distinct ${bookSeriesMemberships.bookId})::int`;
const [countResult, itemRows] = await Promise.all([
this.db
.select({ total: sql<number>`count(distinct ${bookSeries.id})::int` })
.from(bookSeries)
.innerJoin(bookMetadata, eq(bookMetadata.seriesId, bookSeries.id))
.innerJoin(books, eq(books.id, bookMetadata.bookId))
.innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.seriesId, bookSeries.id))
.innerJoin(books, eq(books.id, bookSeriesMemberships.bookId))
.where(where),
this.db
.select({ id: bookSeries.id, name: bookSeries.name, bookCount: bookCountExpr })
.from(bookSeries)
.innerJoin(bookMetadata, eq(bookMetadata.seriesId, bookSeries.id))
.innerJoin(books, eq(books.id, bookMetadata.bookId))
.innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.seriesId, bookSeries.id))
.innerJoin(books, eq(books.id, bookSeriesMemberships.bookId))
.where(where)
.groupBy(bookSeries.id, bookSeries.name)
.orderBy(
@@ -169,10 +169,8 @@ export class SeriesStrategy implements EntityStrategy {
if (affectedBookIds.length === 0) return { affectedBookIds };
await this.db.transaction(async (tx) => {
await tx
.update(bookMetadata)
.set({ seriesId: targetId, seriesName: target.name, updatedAt: new Date() })
.where(and(inArray(bookMetadata.bookId, affectedBookIds), inArray(bookMetadata.seriesId, sourceIds)));
await this.mergeMemberships(sourceIds, targetId, affectedBookIds, tx as unknown as Db);
await this.syncPrimaryMetadataForBooks(affectedBookIds, tx as unknown as Db);
await this.deleteUnusedSeriesRows(sourceIds, tx as unknown as Db);
});
@@ -200,10 +198,10 @@ export class SeriesStrategy implements EntityStrategy {
const wasImplicitMerge = target.id !== entityId;
await this.db.transaction(async (tx) => {
await tx
.update(bookMetadata)
.set({ seriesId: target.id, seriesName: target.name, updatedAt: new Date() })
.where(and(inArray(bookMetadata.bookId, affectedBookIds), eq(bookMetadata.seriesId, entityId)));
if (wasImplicitMerge) {
await this.mergeMemberships([entityId], target.id, affectedBookIds, tx as unknown as Db);
}
await this.syncPrimaryMetadataForBooks(affectedBookIds, tx as unknown as Db);
await this.deleteUnusedSeriesRows([entityId], tx as unknown as Db);
});
@@ -224,9 +222,9 @@ export class SeriesStrategy implements EntityStrategy {
if (affectedBookIds.length > 0) {
await this.db.transaction(async (tx) => {
await tx
.update(bookMetadata)
.set({ seriesId: null, seriesName: null, updatedAt: new Date() })
.where(and(inArray(bookMetadata.bookId, affectedBookIds), eq(bookMetadata.seriesId, entityId)));
.delete(bookSeriesMemberships)
.where(and(inArray(bookSeriesMemberships.bookId, affectedBookIds), eq(bookSeriesMemberships.seriesId, entityId)));
await this.syncPrimaryMetadataForBooks(affectedBookIds, tx as unknown as Db);
await this.deleteUnusedSeriesRows([entityId], tx as unknown as Db);
});
}
@@ -241,23 +239,27 @@ export class SeriesStrategy implements EntityStrategy {
async findAffectedBookIds(ids: (number | string)[]): Promise<number[]> {
const seriesIds = numericIds(ids);
if (seriesIds.length === 0) return [];
const rows = await this.db.select({ bookId: bookMetadata.bookId }).from(bookMetadata).where(inArray(bookMetadata.seriesId, seriesIds));
return rows.map((row) => row.bookId);
const rows = await this.db
.select({ bookId: bookSeriesMemberships.bookId })
.from(bookSeriesMemberships)
.where(inArray(bookSeriesMemberships.seriesId, seriesIds));
return [...new Set(rows.map((row) => row.bookId))];
}
async getBookCount(id: number | string): Promise<number> {
const [row] = await this.db
.select({ count: count() })
.from(bookMetadata)
.where(eq(bookMetadata.seriesId, Number(id)));
.from(bookSeriesMemberships)
.where(eq(bookSeriesMemberships.seriesId, Number(id)));
return row?.count ?? 0;
}
async getBookTitles(id: number | string, limit: number): Promise<string[]> {
const rows = await this.db
.select({ title: sql<string>`COALESCE(${bookMetadata.title}, 'Untitled')` })
.from(bookMetadata)
.where(eq(bookMetadata.seriesId, Number(id)))
.from(bookSeriesMemberships)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, bookSeriesMemberships.bookId))
.where(eq(bookSeriesMemberships.seriesId, Number(id)))
.orderBy(asc(bookMetadata.title))
.limit(limit);
return rows.map((row) => row.title);
@@ -289,11 +291,110 @@ export class SeriesStrategy implements EntityStrategy {
private async findAffectedBookIdsInLibraries(seriesIds: number[], libraryIds: number[] | undefined): Promise<number[]> {
if (seriesIds.length === 0 || !libraryIds || libraryIds.length === 0) return [];
const rows = await this.db
.select({ bookId: bookMetadata.bookId })
.from(bookMetadata)
.innerJoin(books, eq(books.id, bookMetadata.bookId))
.where(and(inArray(bookMetadata.seriesId, seriesIds), inArray(books.libraryId, libraryIds)));
return rows.map((row) => row.bookId);
.select({ bookId: bookSeriesMemberships.bookId })
.from(bookSeriesMemberships)
.innerJoin(books, eq(books.id, bookSeriesMemberships.bookId))
.where(and(inArray(bookSeriesMemberships.seriesId, seriesIds), inArray(books.libraryId, libraryIds)));
return [...new Set(rows.map((row) => row.bookId))];
}
private async mergeMemberships(sourceIds: number[], targetId: number, affectedBookIds: number[], db: Db): Promise<void> {
const sourceSeriesIds = numericIds(sourceIds);
const bookIds = numericIds(affectedBookIds);
if (sourceSeriesIds.length === 0 || bookIds.length === 0) return;
const sourceIdList = sqlNumberList(sourceSeriesIds);
const bookIdList = sqlNumberList(bookIds);
await db.execute(sql`
UPDATE book_series_memberships bsm
SET series_id = ${targetId}, updated_at = now()
WHERE bsm.book_id IN (${bookIdList})
AND bsm.series_id IN (${sourceIdList})
AND NOT EXISTS (
SELECT 1
FROM book_series_memberships existing
WHERE existing.book_id = bsm.book_id
AND existing.series_id = ${targetId}
)
`);
await db.execute(sql`
DELETE FROM book_series_memberships
WHERE book_id IN (${bookIdList})
AND series_id IN (${sourceIdList})
`);
await this.renumberMemberships(bookIds, db);
}
private async renumberMemberships(bookIds: number[], db: Db): Promise<void> {
const scopedBookIds = numericIds(bookIds);
if (scopedBookIds.length === 0) return;
const bookIdList = sqlNumberList(scopedBookIds);
await db.execute(sql`
UPDATE book_series_memberships
SET display_order = display_order + 100000
WHERE book_id IN (${bookIdList})
`);
await db.execute(sql`
WITH ranked AS (
SELECT
book_id,
series_id,
row_number() OVER (PARTITION BY book_id ORDER BY display_order ASC, series_id ASC) - 1 AS next_display_order
FROM book_series_memberships
WHERE book_id IN (${bookIdList})
)
UPDATE book_series_memberships bsm
SET display_order = ranked.next_display_order,
updated_at = now()
FROM ranked
WHERE bsm.book_id = ranked.book_id
AND bsm.series_id = ranked.series_id
`);
}
private async syncPrimaryMetadataForBooks(bookIds: number[], db: Db): Promise<void> {
const scopedBookIds = numericIds(bookIds);
if (scopedBookIds.length === 0) return;
const bookIdList = sqlNumberList(scopedBookIds);
await this.renumberMemberships(scopedBookIds, db);
await db.execute(sql`
UPDATE book_metadata bm
SET series_id = bsm.series_id,
series_name = bs.name,
series_index = bsm.series_index,
updated_at = now()
FROM book_series_memberships bsm
JOIN book_series bs ON bs.id = bsm.series_id
WHERE bm.book_id = bsm.book_id
AND bsm.display_order = 0
AND bm.book_id IN (${bookIdList})
`);
await db.execute(sql`
UPDATE book_metadata bm
SET series_id = NULL,
series_name = NULL,
series_index = NULL,
updated_at = now()
WHERE bm.book_id IN (${bookIdList})
AND NOT EXISTS (
SELECT 1
FROM book_series_memberships bsm
WHERE bsm.book_id = bm.book_id
)
`);
await db.execute(sql`
UPDATE books
SET updated_at = now()
WHERE id IN (${bookIdList})
`);
}
private async deleteUnusedSeriesRows(seriesIds: number[], db: Db): Promise<void> {
@@ -304,8 +405,8 @@ export class SeriesStrategy implements EntityStrategy {
DELETE FROM book_series s
WHERE s.id IN (${seriesIdList})
AND NOT EXISTS (
SELECT 1 FROM book_metadata bm
WHERE bm.series_id = s.id
SELECT 1 FROM book_series_memberships bsm
WHERE bsm.series_id = s.id
)
`);
}
@@ -16,6 +16,7 @@ import {
} from '../../common/book-cover-storage';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { SeriesIdentityService } from '../../common/services/series-identity.service';
import { SeriesMembershipService } from '../../common/services/series-membership.service';
import { refreshPrimaryAuthorSortNamesForBooks } from '../../db/book-author-sort-key';
import { BookEmbedderService } from '../embedding/book-embedder.service';
import { BookMetadataLockService } from '../book-metadata-lock/book-metadata-lock.service';
@@ -75,6 +76,7 @@ export class MetadataService {
@Optional() private readonly embedder: BookEmbedderService,
@Optional() private readonly metadataEvents?: MetadataEventsService,
@Optional() private readonly seriesIdentity?: SeriesIdentityService,
@Optional() private readonly seriesMemberships?: SeriesMembershipService,
) {
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
const audio = new AudioFormatExtractor();
@@ -540,9 +542,14 @@ export class MetadataService {
if (filtered.audioMetadata?.durationSeconds !== undefined) scalarFields.durationSeconds = filtered.audioMetadata.durationSeconds;
if (filtered.audioMetadata?.chapters !== undefined) scalarFields.chapters = filtered.audioMetadata.chapters;
if (Object.keys(scalarFields).length > 0) {
const shouldSyncSeries =
Object.prototype.hasOwnProperty.call(scalarFields, 'seriesName') || Object.prototype.hasOwnProperty.call(scalarFields, 'seriesIndex');
scalarFields.updatedAt = new Date();
const patch = (await this.seriesIdentity?.resolveMetadataPatch(scalarFields)) ?? scalarFields;
await this.db.update(bookMetadata).set(patch).where(eq(bookMetadata.bookId, bookId));
if (shouldSyncSeries) {
await this.seriesMemberships?.syncPrimaryFromMetadata(bookId);
}
}
if (filtered.authors !== undefined) {
@@ -611,9 +618,14 @@ export class MetadataService {
if (filtered.lubimyczytacId !== undefined) scalarFields.lubimyczytacId = filtered.lubimyczytacId;
if (filtered.itunesId !== undefined) scalarFields.itunesId = filtered.itunesId;
if (Object.keys(scalarFields).length > 0) {
const shouldSyncSeries =
Object.prototype.hasOwnProperty.call(scalarFields, 'seriesName') || Object.prototype.hasOwnProperty.call(scalarFields, 'seriesIndex');
scalarFields.updatedAt = new Date();
const patch = (await this.seriesIdentity?.resolveMetadataPatch(scalarFields)) ?? scalarFields;
await this.db.update(bookMetadata).set(patch).where(eq(bookMetadata.bookId, bookId));
if (shouldSyncSeries) {
await this.seriesMemberships?.syncPrimaryFromMetadata(bookId);
}
}
if (filtered.authors !== undefined) {
@@ -6,6 +6,7 @@ import { DB } from '../../../db';
import { refreshPrimaryAuthorSortNamesForAuthors, refreshPrimaryAuthorSortNamesForBooks } from '../../../db/book-author-sort-key';
import * as schema from '../../../db/schema';
import { SeriesIdentityService } from '../../../common/services/series-identity.service';
import { SeriesMembershipService } from '../../../common/services/series-membership.service';
import { uniqueNumbers } from './executor-utils';
type Db = NodePgDatabase<typeof schema>;
@@ -74,20 +75,28 @@ export class MigrationImportRepository {
constructor(
@Inject(DB) private readonly db: Db,
@Optional() private readonly seriesIdentity?: SeriesIdentityService,
@Optional() private readonly seriesMemberships?: SeriesMembershipService,
) {}
async withTransaction<T>(handler: (repo: MigrationImportRepository) => Promise<T>): Promise<T> {
return this.db.transaction(async (tx) => handler(new MigrationImportRepository(tx as unknown as Db, this.seriesIdentity)));
return this.db.transaction(async (tx) =>
handler(new MigrationImportRepository(tx as unknown as Db, this.seriesIdentity, this.seriesMemberships)),
);
}
// --- Metadata ---
async upsertBookMetadata(bookId: number, values: Partial<typeof schema.bookMetadata.$inferInsert>): Promise<void> {
const shouldSyncSeries =
Object.prototype.hasOwnProperty.call(values, 'seriesName') || Object.prototype.hasOwnProperty.call(values, 'seriesIndex');
const patch = (await this.seriesIdentity?.resolveMetadataPatch(values, this.db)) ?? values;
await this.db
.insert(schema.bookMetadata)
.values({ bookId, ...patch })
.onConflictDoUpdate({ target: schema.bookMetadata.bookId, set: patch });
if (shouldSyncSeries) {
await this.seriesMemberships?.syncPrimaryFromMetadata(bookId, this.db);
}
}
// --- Authors ---
@@ -303,12 +312,17 @@ export class MigrationImportRepository {
if (items.length === 0) return;
for (const batch of chunk(items, BATCH_CHUNK_SIZE)) {
for (const item of batch) {
const shouldSyncSeries =
Object.prototype.hasOwnProperty.call(item, 'seriesName') || Object.prototype.hasOwnProperty.call(item, 'seriesIndex');
const patch = (await this.seriesIdentity?.resolveMetadataPatch(item, this.db)) ?? item;
const set = bookMetadataConflictSet(patch);
await this.db.insert(schema.bookMetadata).values(patch).onConflictDoUpdate({
target: schema.bookMetadata.bookId,
set,
});
if (shouldSyncSeries) {
await this.seriesMemberships?.syncPrimaryFromMetadata(item.bookId, this.db);
}
}
}
}
+39 -19
View File
@@ -6,7 +6,7 @@ import type { ContentFilterRules } from '@bookorbit/types';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookMetadata, books, bookSeries, userBookStatus } from '../../db/schema';
import { authors, bookAuthors, bookMetadata, books, bookSeries, bookSeriesMemberships, userBookStatus } from '../../db/schema';
import type { SeriesListSort, SortDirection } from './dto/list-series.dto';
import type { SeriesBookSort } from './dto/list-series-books.dto';
@@ -66,7 +66,7 @@ export class SeriesRepository {
const libraryFilter = this.buildLibraryFilter(params.libraryIds);
const filterClauses = params.contentFilters ? buildContentFilterClauses(params.contentFilters, this.db) : [];
const conditions: SQL[] = [isNotNull(bookMetadata.seriesId), libraryFilter, ...filterClauses];
const conditions: SQL[] = [libraryFilter, ...filterClauses];
if (params.q) {
const qPattern = `%${this.escapeLikePattern(params.q)}%`;
@@ -103,7 +103,8 @@ export class SeriesRepository {
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.innerJoin(bookSeries, eq(bookSeries.id, bookMetadata.seriesId))
.innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.bookId, books.id))
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
.leftJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, params.userId)))
.where(baseWhere)
.groupBy(bookSeries.id, bookSeries.name);
@@ -122,7 +123,8 @@ export class SeriesRepository {
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.innerJoin(bookSeries, eq(bookSeries.id, bookMetadata.seriesId))
.innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.bookId, books.id))
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
.leftJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, params.userId)))
.where(baseWhere)
.groupBy(bookSeries.id, bookSeries.name)
@@ -180,7 +182,8 @@ export class SeriesRepository {
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.innerJoin(bookSeries, eq(bookSeries.id, bookMetadata.seriesId))
.innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.bookId, books.id))
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
.leftJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, params.userId)))
.where(and(eq(bookSeries.id, params.seriesId), libraryFilter, ...filterClauses))
.groupBy(bookSeries.id, bookSeries.name);
@@ -192,10 +195,12 @@ export class SeriesRepository {
const [authorsMap, indicesRows] = await Promise.all([
this.fetchAuthorsForSeries([params.seriesId], params.libraryIds, params.contentFilters),
this.db
.select({ idx: bookMetadata.seriesIndex })
.select({ idx: bookSeriesMemberships.seriesIndex })
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(and(eq(bookMetadata.seriesId, params.seriesId), libraryFilter, ...filterClauses, isNotNull(bookMetadata.seriesIndex))),
.innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.bookId, books.id))
.where(
and(eq(bookSeriesMemberships.seriesId, params.seriesId), libraryFilter, ...filterClauses, isNotNull(bookSeriesMemberships.seriesIndex)),
),
]);
const indices = indicesRows.map((r) => r.idx!);
@@ -221,7 +226,7 @@ export class SeriesRepository {
}): Promise<{ bookIds: number[]; total: number }> {
const libraryFilter = this.buildLibraryFilter(params.libraryIds);
const filterClauses = params.contentFilters ? buildContentFilterClauses(params.contentFilters, this.db) : [];
const where = and(eq(bookMetadata.seriesId, params.seriesId), libraryFilter, ...filterClauses)!;
const where = and(eq(bookSeriesMemberships.seriesId, params.seriesId), libraryFilter, ...filterClauses)!;
const orderBy = this.buildBookSortExpression(params.sort, params.order);
@@ -229,12 +234,18 @@ export class SeriesRepository {
this.db
.select({ id: books.id })
.from(books)
.innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.bookId, books.id))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(where)
.orderBy(...orderBy)
.limit(params.size)
.offset(params.page * params.size),
this.db.select({ total: count() }).from(books).innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id)).where(where),
this.db
.select({ total: count() })
.from(books)
.innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.bookId, books.id))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(where),
]);
return { bookIds: dataRows.map((r) => r.id), total: Number(total) };
@@ -250,15 +261,15 @@ export class SeriesRepository {
const filterClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
const rows = await this.db
.select({
seriesId: bookMetadata.seriesId,
seriesId: bookSeriesMemberships.seriesId,
authorName: authors.name,
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.bookId, books.id))
.innerJoin(bookAuthors, eq(bookAuthors.bookId, books.id))
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(and(inArray(bookMetadata.seriesId, seriesIds), this.buildLibraryFilter(libraryIds), ...filterClauses))
.groupBy(bookMetadata.seriesId, authors.name);
.where(and(inArray(bookSeriesMemberships.seriesId, seriesIds), this.buildLibraryFilter(libraryIds), ...filterClauses))
.groupBy(bookSeriesMemberships.seriesId, authors.name);
const result = new Map<number, string[]>();
for (const row of rows) {
@@ -276,16 +287,22 @@ export class SeriesRepository {
const filterClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
const rows = await this.db
.select({
seriesId: bookMetadata.seriesId,
seriesId: bookSeriesMemberships.seriesId,
bookId: books.id,
seriesIndex: bookMetadata.seriesIndex,
seriesIndex: bookSeriesMemberships.seriesIndex,
})
.from(books)
.innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.bookId, books.id))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(
and(inArray(bookMetadata.seriesId, seriesIds), this.buildLibraryFilter(libraryIds), ...filterClauses, isNotNull(bookMetadata.coverSource)),
and(
inArray(bookSeriesMemberships.seriesId, seriesIds),
this.buildLibraryFilter(libraryIds),
...filterClauses,
isNotNull(bookMetadata.coverSource),
),
)
.orderBy(bookMetadata.seriesId, asc(bookMetadata.seriesIndex), asc(books.addedAt));
.orderBy(bookSeriesMemberships.seriesId, asc(bookSeriesMemberships.seriesIndex), asc(books.addedAt));
const result = new Map<number, number[]>();
for (const row of rows) {
@@ -348,7 +365,10 @@ export class SeriesRepository {
return [dir(books.addedAt), asc(books.id)];
case 'seriesIndex':
default:
return [order === 'asc' ? sql`${bookMetadata.seriesIndex} ASC NULLS LAST` : sql`${bookMetadata.seriesIndex} DESC NULLS LAST`, asc(books.id)];
return [
order === 'asc' ? sql`${bookSeriesMemberships.seriesIndex} ASC NULLS LAST` : sql`${bookSeriesMemberships.seriesIndex} DESC NULLS LAST`,
asc(books.id),
];
}
}
}
+16 -1
View File
@@ -126,9 +126,24 @@ export class SeriesService {
cardData.genreRows,
cardData.progressRows,
cardData.statusRows,
cardData.narratorRows,
cardData.tagRows,
cardData.seriesMembershipRows,
);
const orderMap = new Map(bookPage.bookIds.map((id, i) => [id, i]));
items = cards.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
items = cards
.map((card) => {
const contextualSeries = (card.seriesMemberships ?? []).find((membership) => membership.seriesId === seriesId);
return contextualSeries
? {
...card,
seriesId: contextualSeries.seriesId,
seriesName: contextualSeries.seriesName,
seriesIndex: contextualSeries.seriesIndex,
}
: card;
})
.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
}
const seriesInfo: SeriesDetail = {