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