mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(series): track expected series length and surface library completeness
Record how many books a series holds from Hardcover, ComicVine, RanobeDB, Amazon and embedded ComicInfo metadata, storing it on the series row. Show an ownership meter beside read progress on series detail, and extend gap detection to report entries past the highest owned index instead of stopping there. Allow the total to be set by hand in the book metadata editor. Automated sources only ever raise the stored total so the result does not depend on scan order; the manual field is the way to lower or clear it. Closes #801
This commit is contained in:
@@ -337,7 +337,7 @@ function applyPrimarySeriesPatch(field: 'seriesName' | 'seriesIndex', value: unk
|
||||
}
|
||||
|
||||
const next = [...form.seriesMemberships]
|
||||
const primary = next[0] ?? { seriesName: form.seriesName ?? '', seriesIndex: form.seriesIndex ?? null }
|
||||
const primary = next[0] ?? { seriesName: form.seriesName ?? '', seriesIndex: form.seriesIndex ?? null, expectedBookCount: null }
|
||||
const patched =
|
||||
field === 'seriesName'
|
||||
? { ...primary, seriesName: typeof value === 'string' ? value : value == null ? '' : String(value) }
|
||||
@@ -360,11 +360,16 @@ function applySeriesMembershipPatch(formPatch: MetadataPatch, skippedFields: Boo
|
||||
return 0
|
||||
}
|
||||
|
||||
// Providers do not report series length in this patch, so carry the current value across
|
||||
// rather than letting an applied suggestion silently clear a total someone entered.
|
||||
const totalsByName = new Map(form.seriesMemberships.map((m) => [m.seriesName.trim().toLowerCase(), m.expectedBookCount ?? null]))
|
||||
|
||||
setSeriesMemberships(
|
||||
normalizeSeriesMemberships(
|
||||
(formPatch.seriesMemberships ?? []).map((membership) => ({
|
||||
seriesName: membership.seriesName,
|
||||
seriesIndex: normalizeSeriesIndex(membership.seriesIndex),
|
||||
expectedBookCount: totalsByName.get(membership.seriesName.trim().toLowerCase()) ?? null,
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ const { t } = useI18n()
|
||||
const emit = defineEmits<{ 'update:modelValue': [EditableSeriesMembership[]] }>()
|
||||
|
||||
const visibleMemberships = computed<EditableSeriesMembership[]>(() =>
|
||||
props.modelValue.length > 0 ? props.modelValue : [{ seriesName: '', seriesIndex: null }],
|
||||
props.modelValue.length > 0 ? props.modelValue : [{ seriesName: '', seriesIndex: null, expectedBookCount: null }],
|
||||
)
|
||||
|
||||
const rowKeys = ref<string[]>([])
|
||||
@@ -47,7 +47,7 @@ function getRowKey(index: number) {
|
||||
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 }])
|
||||
emit('update:modelValue', [{ seriesName: '', seriesIndex: null, expectedBookCount: null, ...patch }])
|
||||
return
|
||||
}
|
||||
const next = props.modelValue.map((membership, i) => (i === index ? { ...membership, ...patch } : membership))
|
||||
@@ -63,12 +63,18 @@ function updateSeriesIndex(index: number, event: Event) {
|
||||
updateMembership(index, { seriesIndex: raw === '' ? null : Number.parseFloat(raw) })
|
||||
}
|
||||
|
||||
function updateExpectedBookCount(index: number, event: Event) {
|
||||
const raw = (event.target as HTMLInputElement).value
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
updateMembership(index, { expectedBookCount: raw === '' || Number.isNaN(parsed) ? null : parsed })
|
||||
}
|
||||
|
||||
function addMembership() {
|
||||
if (props.disabled) return
|
||||
if (props.modelValue.length > 0) {
|
||||
rowKeys.value = [...rowKeys.value, createRowKey()]
|
||||
}
|
||||
emit('update:modelValue', [...props.modelValue, { seriesName: '', seriesIndex: null }])
|
||||
emit('update:modelValue', [...props.modelValue, { seriesName: '', seriesIndex: null, expectedBookCount: null }])
|
||||
}
|
||||
|
||||
function removeMembership(index: number) {
|
||||
@@ -101,7 +107,7 @@ function moveMembership(index: number, offset: -1 | 1) {
|
||||
<div
|
||||
v-for="(membership, index) in visibleMemberships"
|
||||
:key="getRowKey(index)"
|
||||
class="grid grid-cols-[2rem_minmax(0,1fr)_5.5rem_auto] items-center gap-2"
|
||||
class="grid grid-cols-[2rem_minmax(0,1fr)_5.5rem_5.5rem_auto] items-center gap-2"
|
||||
>
|
||||
<button
|
||||
v-if="index === 0"
|
||||
@@ -131,8 +137,21 @@ function moveMembership(index: number, offset: -1 | 1) {
|
||||
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="#"
|
||||
:aria-label="t('book.detail.seriesMembership.seriesIndexLabel')"
|
||||
@input="updateSeriesIndex(index, $event)"
|
||||
/>
|
||||
<input
|
||||
:value="membership.expectedBookCount ?? ''"
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
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="t('book.detail.seriesMembership.totalPlaceholder')"
|
||||
:aria-label="t('book.detail.seriesMembership.totalLabel')"
|
||||
:title="t('book.detail.seriesMembership.totalHint')"
|
||||
@input="updateExpectedBookCount(index, $event)"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -329,9 +329,9 @@ describe('DetailsTab cover surface', () => {
|
||||
seriesName: 'Mistborn Era 2',
|
||||
seriesIndex: 4,
|
||||
seriesMemberships: [
|
||||
{ seriesId: 22, seriesName: 'Cosmere', seriesIndex: null, displayOrder: 2 },
|
||||
{ seriesId: 20, seriesName: 'Mistborn Era 2', seriesIndex: 4, displayOrder: 0 },
|
||||
{ seriesId: 21, seriesName: 'Mistborn Saga', seriesIndex: 7.5, displayOrder: 1 },
|
||||
{ seriesId: 22, seriesName: 'Cosmere', seriesIndex: null, displayOrder: 2, expectedBookCount: null },
|
||||
{ seriesId: 20, seriesName: 'Mistborn Era 2', seriesIndex: 4, displayOrder: 0, expectedBookCount: null },
|
||||
{ seriesId: 21, seriesName: 'Mistborn Saga', seriesIndex: 7.5, displayOrder: 1, expectedBookCount: null },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
+61
-1
@@ -60,6 +60,66 @@ describe('SeriesMembershipEditor', () => {
|
||||
const finalInput = wrapper.get<HTMLInputElement>('input[type="text"]')
|
||||
expect(finalInput.element).toBe(input.element)
|
||||
expect(document.activeElement).toBe(input.element)
|
||||
expect((wrapper.vm as { memberships: EditableSeriesMembership[] }).memberships).toEqual([{ seriesName: 'Dune', seriesIndex: null }])
|
||||
expect((wrapper.vm as { memberships: EditableSeriesMembership[] }).memberships).toEqual([
|
||||
{ seriesName: 'Dune', seriesIndex: null, expectedBookCount: null },
|
||||
])
|
||||
})
|
||||
|
||||
describe('series length', () => {
|
||||
function membershipsOf(wrapper: ReturnType<typeof mountHost>): EditableSeriesMembership[] {
|
||||
return (wrapper.vm as { memberships: EditableSeriesMembership[] }).memberships
|
||||
}
|
||||
|
||||
function totalInput(wrapper: ReturnType<typeof mountHost>) {
|
||||
return wrapper.get<HTMLInputElement>('input[type="number"][min="1"]')
|
||||
}
|
||||
|
||||
it('shows the stored series length', () => {
|
||||
const wrapper = mountHost([{ seriesName: 'Dune', seriesIndex: 1, expectedBookCount: 6 }])
|
||||
|
||||
expect(totalInput(wrapper).element.value).toBe('6')
|
||||
})
|
||||
|
||||
it('records a typed length as an integer', async () => {
|
||||
const wrapper = mountHost([{ seriesName: 'Dune', seriesIndex: 1, expectedBookCount: null }])
|
||||
|
||||
await totalInput(wrapper).setValue('7')
|
||||
|
||||
expect(membershipsOf(wrapper)[0]?.expectedBookCount).toBe(7)
|
||||
})
|
||||
|
||||
it('clears the length when the field is emptied', async () => {
|
||||
const wrapper = mountHost([{ seriesName: 'Dune', seriesIndex: 1, expectedBookCount: 6 }])
|
||||
|
||||
await totalInput(wrapper).setValue('')
|
||||
|
||||
expect(membershipsOf(wrapper)[0]?.expectedBookCount).toBeNull()
|
||||
})
|
||||
|
||||
it('treats an unparseable length as cleared rather than NaN', async () => {
|
||||
const wrapper = mountHost([{ seriesName: 'Dune', seriesIndex: 1, expectedBookCount: 6 }])
|
||||
|
||||
await totalInput(wrapper).setValue('abc')
|
||||
|
||||
expect(membershipsOf(wrapper)[0]?.expectedBookCount).toBeNull()
|
||||
})
|
||||
|
||||
it('labels the length field for assistive technology, since the row has no visible labels', () => {
|
||||
const wrapper = mountHost([{ seriesName: 'Dune', seriesIndex: 1, expectedBookCount: 6 }])
|
||||
|
||||
expect(totalInput(wrapper).attributes('aria-label')).toBe('Books in series')
|
||||
})
|
||||
|
||||
it('keeps each row length independent', async () => {
|
||||
const wrapper = mountHost([
|
||||
{ seriesName: 'Dune', seriesIndex: 1, expectedBookCount: 6 },
|
||||
{ seriesName: 'Legends', seriesIndex: 2, expectedBookCount: null },
|
||||
])
|
||||
|
||||
const totals = wrapper.findAll<HTMLInputElement>('input[type="number"][min="1"]')
|
||||
await totals[1]!.setValue('3')
|
||||
|
||||
expect(membershipsOf(wrapper).map((m) => m.expectedBookCount)).toEqual([6, 3])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
export type EditableSeriesMembership = {
|
||||
seriesName: string
|
||||
seriesIndex: number | null
|
||||
/** Series-level: saving it changes the total for every book in the series and every user. */
|
||||
expectedBookCount: number | null
|
||||
}
|
||||
|
||||
const ROOT_FIELDS = [
|
||||
@@ -79,7 +81,7 @@ export function normalizeSeriesMemberships(values: readonly EditableSeriesMember
|
||||
const key = seriesName.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push({ seriesName, seriesIndex: value.seriesIndex ?? null })
|
||||
out.push({ seriesName, seriesIndex: value.seriesIndex ?? null, expectedBookCount: value.expectedBookCount ?? null })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -87,9 +89,15 @@ export function normalizeSeriesMemberships(values: readonly EditableSeriesMember
|
||||
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 normalizeSeriesMemberships(
|
||||
memberships.map((membership) => ({
|
||||
seriesName: membership.seriesName,
|
||||
seriesIndex: membership.seriesIndex,
|
||||
expectedBookCount: membership.expectedBookCount ?? null,
|
||||
})),
|
||||
)
|
||||
}
|
||||
return book.seriesName ? [{ seriesName: book.seriesName, seriesIndex: book.seriesIndex }] : []
|
||||
return book.seriesName ? [{ seriesName: book.seriesName, seriesIndex: book.seriesIndex, expectedBookCount: null }] : []
|
||||
}
|
||||
|
||||
function changedCustomMetadataPayload(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import SeriesOwnershipBar from './SeriesOwnershipBar.vue'
|
||||
|
||||
function mountBar(ownedCount: number, expectedCount: number): VueWrapper {
|
||||
return mount(SeriesOwnershipBar, { props: { ownedCount, expectedCount } })
|
||||
}
|
||||
|
||||
function fillStyle(wrapper: VueWrapper): string {
|
||||
return wrapper.get('[role="progressbar"] > div').attributes('style') ?? ''
|
||||
}
|
||||
|
||||
describe('SeriesOwnershipBar', () => {
|
||||
it('states how many of the series is in the library', () => {
|
||||
expect(mountBar(4, 7).text()).toContain('4 of 7 in library')
|
||||
})
|
||||
|
||||
it('captions below the track, matching the read-progress bar it sits beside', () => {
|
||||
const wrapper = mountBar(4, 7)
|
||||
const children = Array.from(wrapper.element.children) as Element[]
|
||||
|
||||
expect(children[0]?.getAttribute('role')).toBe('progressbar')
|
||||
expect(children[1]?.tagName).toBe('P')
|
||||
})
|
||||
|
||||
it('fills the bar in proportion to what is owned', () => {
|
||||
expect(fillStyle(mountBar(4, 7))).toContain('width: 57%')
|
||||
expect(fillStyle(mountBar(1, 4))).toContain('width: 25%')
|
||||
expect(fillStyle(mountBar(0, 5))).toContain('width: 0%')
|
||||
})
|
||||
|
||||
it('marks a complete series distinctly from a partial one', () => {
|
||||
expect(mountBar(7, 7).get('[role="progressbar"] > div').classes()).toContain('bg-green-500')
|
||||
expect(mountBar(6, 7).get('[role="progressbar"] > div').classes()).toContain('bg-primary')
|
||||
})
|
||||
|
||||
it('caps the fill when the library holds more than the provider expects', () => {
|
||||
const wrapper = mountBar(9, 7)
|
||||
|
||||
expect(fillStyle(wrapper)).toContain('width: 100%')
|
||||
expect(wrapper.get('[role="progressbar"] > div').classes()).toContain('bg-green-500')
|
||||
})
|
||||
|
||||
it('does not divide by zero when the expected total is zero', () => {
|
||||
const style = fillStyle(mountBar(0, 0))
|
||||
|
||||
expect(style).toContain('width: 0%')
|
||||
expect(style).not.toContain('NaN')
|
||||
})
|
||||
|
||||
it('exposes progress to assistive technology rather than by colour alone', () => {
|
||||
const bar = mountBar(4, 7).get('[role="progressbar"]')
|
||||
|
||||
expect(bar.attributes('aria-valuenow')).toBe('4')
|
||||
expect(bar.attributes('aria-valuemin')).toBe('0')
|
||||
expect(bar.attributes('aria-valuemax')).toBe('7')
|
||||
expect(bar.attributes('aria-valuetext')).toBe('4 of 7 in library')
|
||||
expect(bar.attributes('aria-label')).toBe('In library')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
ownedCount: number
|
||||
expectedCount: number
|
||||
}>()
|
||||
|
||||
const percentage = computed(() => {
|
||||
if (props.expectedCount <= 0) return 0
|
||||
return Math.min(100, Math.round((props.ownedCount / props.expectedCount) * 100))
|
||||
})
|
||||
|
||||
const barColorClass = computed(() => (props.ownedCount >= props.expectedCount ? 'bg-green-500' : 'bg-primary'))
|
||||
|
||||
const caption = computed(() => t('series.ownership.ownedOfExpected', { owned: props.ownedCount, expected: props.expectedCount }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="h-1 w-full overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
:aria-label="t('series.ownership.label')"
|
||||
:aria-valuenow="ownedCount"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="expectedCount"
|
||||
:aria-valuetext="caption"
|
||||
>
|
||||
<div class="h-full rounded-full transition-all duration-300" :class="barColorClass" :style="{ width: `${percentage}%` }" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ caption }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -36,7 +36,7 @@ describe('useSeriesDetail', () => {
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 50,
|
||||
seriesInfo: { id: 42, name: 'Dune', bookCount: 6, readCount: 2, authors: ['Frank Herbert'], possibleGaps: [3] },
|
||||
seriesInfo: { id: 42, name: 'Dune', bookCount: 6, readCount: 2, authors: ['Frank Herbert'], possibleGaps: [3], expectedBookCount: null },
|
||||
})
|
||||
|
||||
const { seriesInfo, items, total, load } = useSeriesDetail(seriesId)
|
||||
@@ -106,7 +106,7 @@ describe('useSeriesDetail', () => {
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 50,
|
||||
seriesInfo: { id: 42, name: 'Stale', bookCount: 1, readCount: 0, authors: [], possibleGaps: [] },
|
||||
seriesInfo: { id: 42, name: 'Stale', bookCount: 1, readCount: 0, authors: [], possibleGaps: [], expectedBookCount: null },
|
||||
})
|
||||
await pendingLoad
|
||||
|
||||
@@ -122,14 +122,14 @@ describe('useSeriesDetail', () => {
|
||||
total: 2,
|
||||
page: 0,
|
||||
size: 50,
|
||||
seriesInfo: { id: 42, name: 'Test', bookCount: 2, readCount: 0, authors: [], possibleGaps: [] },
|
||||
seriesInfo: { id: 42, name: 'Test', bookCount: 2, readCount: 0, authors: [], possibleGaps: [], expectedBookCount: null },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ id: 2 } as BookCard],
|
||||
total: 2,
|
||||
page: 1,
|
||||
size: 50,
|
||||
seriesInfo: { id: 42, name: 'Test', bookCount: 2, readCount: 0, authors: [], possibleGaps: [] },
|
||||
seriesInfo: { id: 42, name: 'Test', bookCount: 2, readCount: 0, authors: [], possibleGaps: [], expectedBookCount: null },
|
||||
})
|
||||
|
||||
const { items, load } = useSeriesDetail(seriesId)
|
||||
@@ -153,7 +153,7 @@ describe('useSeriesDetail', () => {
|
||||
total: 0,
|
||||
page: 0,
|
||||
size: 50,
|
||||
seriesInfo: { id: 42, name: 'Found', bookCount: 0, readCount: 0, authors: [], possibleGaps: [] },
|
||||
seriesInfo: { id: 42, name: 'Found', bookCount: 0, readCount: 0, authors: [], possibleGaps: [], expectedBookCount: null },
|
||||
})
|
||||
|
||||
await load(true)
|
||||
@@ -167,7 +167,7 @@ describe('useSeriesDetail', () => {
|
||||
total: 5,
|
||||
page: 0,
|
||||
size: 50,
|
||||
seriesInfo: { id: 42, name: 'Test', bookCount: 5, readCount: 2, authors: ['Author'], possibleGaps: [] },
|
||||
seriesInfo: { id: 42, name: 'Test', bookCount: 5, readCount: 2, authors: ['Author'], possibleGaps: [], expectedBookCount: null },
|
||||
})
|
||||
|
||||
const { seriesInfo, total, load } = useSeriesDetail(seriesId)
|
||||
@@ -192,7 +192,7 @@ describe('useSeriesDetail', () => {
|
||||
total: 0,
|
||||
page: 0,
|
||||
size: 50,
|
||||
seriesInfo: { id: 42, name: 'New', bookCount: 0, readCount: 0, authors: [], possibleGaps: [] },
|
||||
seriesInfo: { id: 42, name: 'New', bookCount: 0, readCount: 0, authors: [], possibleGaps: [], expectedBookCount: null },
|
||||
})
|
||||
await pendingLoad
|
||||
})
|
||||
@@ -204,7 +204,7 @@ describe('useSeriesDetail', () => {
|
||||
total: 5,
|
||||
page: 0,
|
||||
size: 50,
|
||||
seriesInfo: { id: 42, name: 'Test', bookCount: 5, readCount: 2, authors: ['Author'], possibleGaps: [] },
|
||||
seriesInfo: { id: 42, name: 'Test', bookCount: 5, readCount: 2, authors: ['Author'], possibleGaps: [], expectedBookCount: null },
|
||||
})
|
||||
|
||||
const { seriesInfo, total, load } = useSeriesDetail(seriesId)
|
||||
@@ -229,7 +229,7 @@ describe('useSeriesDetail', () => {
|
||||
total: 0,
|
||||
page: 0,
|
||||
size: 50,
|
||||
seriesInfo: { id: 42, name: 'New', bookCount: 0, readCount: 0, authors: [], possibleGaps: [] },
|
||||
seriesInfo: { id: 42, name: 'New', bookCount: 0, readCount: 0, authors: [], possibleGaps: [], expectedBookCount: null },
|
||||
})
|
||||
await pendingLoad
|
||||
})
|
||||
@@ -241,7 +241,7 @@ describe('useSeriesDetail', () => {
|
||||
total: 0,
|
||||
page: 0,
|
||||
size: 50,
|
||||
seriesInfo: { id: 42, name: 'Test', bookCount: 0, readCount: 0, authors: [], possibleGaps: [] },
|
||||
seriesInfo: { id: 42, name: 'Test', bookCount: 0, readCount: 0, authors: [], possibleGaps: [], expectedBookCount: null },
|
||||
})
|
||||
|
||||
const { sort, order, libraryId, load } = useSeriesDetail(seriesId)
|
||||
@@ -288,7 +288,7 @@ describe('useSeriesDetail', () => {
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 50,
|
||||
seriesInfo: { id: 42, name: 'Fresh', bookCount: 1, readCount: 0, authors: [], possibleGaps: [] },
|
||||
seriesInfo: { id: 42, name: 'Fresh', bookCount: 1, readCount: 0, authors: [], possibleGaps: [], expectedBookCount: null },
|
||||
})
|
||||
await resetLoad
|
||||
|
||||
@@ -297,7 +297,7 @@ describe('useSeriesDetail', () => {
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 50,
|
||||
seriesInfo: { id: 42, name: 'Stale', bookCount: 999, readCount: 0, authors: [], possibleGaps: [] },
|
||||
seriesInfo: { id: 42, name: 'Stale', bookCount: 999, readCount: 0, authors: [], possibleGaps: [], expectedBookCount: null },
|
||||
})
|
||||
await firstLoad
|
||||
|
||||
@@ -333,7 +333,7 @@ describe('useSeriesDetail', () => {
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 50,
|
||||
seriesInfo: { id: 42, name: 'OK', bookCount: 1, readCount: 0, authors: [], possibleGaps: [] },
|
||||
seriesInfo: { id: 42, name: 'OK', bookCount: 1, readCount: 0, authors: [], possibleGaps: [], expectedBookCount: null },
|
||||
})
|
||||
await resetLoad
|
||||
await firstLoad
|
||||
|
||||
@@ -138,7 +138,7 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
|
||||
}
|
||||
}
|
||||
|
||||
function makeSeriesInfo(): SeriesDetail {
|
||||
function makeSeriesInfo(overrides: Partial<SeriesDetail> = {}): SeriesDetail {
|
||||
return {
|
||||
id: 42,
|
||||
name: 'The Series',
|
||||
@@ -146,6 +146,8 @@ function makeSeriesInfo(): SeriesDetail {
|
||||
readCount: 0,
|
||||
authors: ['Author'],
|
||||
possibleGaps: [],
|
||||
expectedBookCount: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +307,27 @@ describe('SeriesDetailView', () => {
|
||||
expect(wrapper.get('[data-testid="series-books-section-heading"]').text()).toBe('Books')
|
||||
})
|
||||
|
||||
it('shows the ownership bar only once a provider has supplied a series total', async () => {
|
||||
mocks.seriesInfo = ref(makeSeriesInfo({ bookCount: 4, expectedBookCount: 7 }))
|
||||
|
||||
const wrapper = mountView()
|
||||
await nextTick()
|
||||
|
||||
const bar = wrapper.get('[role="progressbar"]')
|
||||
expect(bar.attributes('aria-valuenow')).toBe('4')
|
||||
expect(bar.attributes('aria-valuemax')).toBe('7')
|
||||
expect(wrapper.text()).toContain('4 of 7')
|
||||
})
|
||||
|
||||
it('hides the ownership bar when no provider total is known', async () => {
|
||||
mocks.seriesInfo = ref(makeSeriesInfo({ bookCount: 4, expectedBookCount: null }))
|
||||
|
||||
const wrapper = mountView()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[role="progressbar"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows grouped media sections with labels and counts when enabled', async () => {
|
||||
localStorage.setItem(GROUP_BY_MEDIA_STORAGE_KEY, 'true')
|
||||
mocks.items = ref([
|
||||
|
||||
@@ -26,6 +26,7 @@ import BookQuickView from '@/features/book/components/BookQuickView.vue'
|
||||
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
|
||||
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
|
||||
import SeriesCompletionBar from '../components/SeriesCompletionBar.vue'
|
||||
import SeriesOwnershipBar from '../components/SeriesOwnershipBar.vue'
|
||||
import SeriesGapBanner from '../components/SeriesGapBanner.vue'
|
||||
import { fetchSeriesBooks } from '../api/series'
|
||||
import { groupSeriesBooksByMedia } from '../composables/useSeriesBookMediaGroups'
|
||||
@@ -550,7 +551,14 @@ defineOptions({ name: 'SeriesDetailView' })
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SeriesCompletionBar :read-count="seriesInfo.readCount" :total-count="seriesInfo.bookCount" class="mt-3 max-w-xs" />
|
||||
<div class="mt-3 grid max-w-md gap-3 sm:grid-cols-2">
|
||||
<SeriesCompletionBar :read-count="seriesInfo.readCount" :total-count="seriesInfo.bookCount" />
|
||||
<SeriesOwnershipBar
|
||||
v-if="seriesInfo.expectedBookCount !== null"
|
||||
:owned-count="seriesInfo.bookCount"
|
||||
:expected-count="seriesInfo.expectedBookCount"
|
||||
/>
|
||||
</div>
|
||||
<SeriesGapBanner v-if="seriesInfo.possibleGaps.length > 0" :gaps="seriesInfo.possibleGaps" class="mt-3" />
|
||||
|
||||
<div class="mt-4 border-t border-border/60 pt-4">
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "Série",
|
||||
"moveUp": "Posunout nahoru",
|
||||
"moveDown": "Přesunout dolů",
|
||||
"removeSeries": "Odstranit sérii"
|
||||
"removeSeries": "Odstranit sérii",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "{read} z {total} přečteno ({percentage}%)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "Možné mezery:"
|
||||
},
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "Serie",
|
||||
"moveUp": "Flyt op",
|
||||
"moveDown": "Flyt ned",
|
||||
"removeSeries": "Fjern serier"
|
||||
"removeSeries": "Fjern serier",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "{read} af {total} læst ({percentage}%)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "Mulige huller:"
|
||||
},
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "Reihe",
|
||||
"moveUp": "Nach oben verschieben",
|
||||
"moveDown": "Nach unten verschieben",
|
||||
"removeSeries": "Reihe entfernen"
|
||||
"removeSeries": "Reihe entfernen",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "{read} von {total} gelesen ({percentage}%)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "Mögliche Lücken:"
|
||||
},
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "Series",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"removeSeries": "Remove series"
|
||||
"removeSeries": "Remove series",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "{read} of {total} read ({percentage}%)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "Possible gaps:"
|
||||
},
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "Serie",
|
||||
"moveUp": "Subir",
|
||||
"moveDown": "Bajar",
|
||||
"removeSeries": "Quitar serie"
|
||||
"removeSeries": "Quitar serie",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "{read} de {total} leído ({percentage}%)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "Posibles lagunas:"
|
||||
},
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "Sarja",
|
||||
"moveUp": "Siirrä ylös",
|
||||
"moveDown": "Siirrä alas",
|
||||
"removeSeries": "Poista sarja"
|
||||
"removeSeries": "Poista sarja",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "{read} of {total} read ({percentage}%)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "Mahdolliset puutteet:"
|
||||
},
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "Série",
|
||||
"moveUp": "Monter",
|
||||
"moveDown": "Descendre",
|
||||
"removeSeries": "Supprimer la série"
|
||||
"removeSeries": "Supprimer la série",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "{read} sur {total} lu ({percentage}%)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "Lacunes possibles :"
|
||||
},
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "Serie",
|
||||
"moveUp": "Vai su",
|
||||
"moveDown": "Spostati giù",
|
||||
"removeSeries": "Rimuovi serie"
|
||||
"removeSeries": "Rimuovi serie",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "{read} di {total} letto ({percentage}%)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "Possibili lacune:"
|
||||
},
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "Reeks",
|
||||
"moveUp": "Omhoog",
|
||||
"moveDown": "Omlaag",
|
||||
"removeSeries": "Reeks verwijderen"
|
||||
"removeSeries": "Reeks verwijderen",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "{read} van {total} gelezen ({percentage}%)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "Mogelijke hiaten:"
|
||||
},
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "Seria",
|
||||
"moveUp": "Przesuń się w górę",
|
||||
"moveDown": "Przesuń w dół",
|
||||
"removeSeries": "Usuń serię"
|
||||
"removeSeries": "Usuń serię",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "{read} z {total} przeczytane ({percentage}%)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "Możliwe luki:"
|
||||
},
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "Série",
|
||||
"moveUp": "Mover para cima",
|
||||
"moveDown": "Mover para baixo",
|
||||
"removeSeries": "Remover série"
|
||||
"removeSeries": "Remover série",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "{read} de {total} lido(s) ({percentage}%)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "Possíveis lacunas:"
|
||||
},
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "Серии",
|
||||
"moveUp": "Вверх",
|
||||
"moveDown": "Сдвинуть вниз",
|
||||
"removeSeries": "Удалить серию"
|
||||
"removeSeries": "Удалить серию",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "{read} прочитанного {total} ({percentage}%)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "Возможные пробелы:"
|
||||
},
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "Serija",
|
||||
"moveUp": "Premakni navzgor",
|
||||
"moveDown": "Premakni navzdol",
|
||||
"removeSeries": "Odstrani serijo"
|
||||
"removeSeries": "Odstrani serijo",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "prebranih {read} od {total} ({percentage} %)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "Možne vrzeli:"
|
||||
},
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "Serie",
|
||||
"moveUp": "Flytta upp",
|
||||
"moveDown": "Flytta ner",
|
||||
"removeSeries": "Ta bort serie"
|
||||
"removeSeries": "Ta bort serie",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "{read} av {total} läst ({percentage}%)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "Möjliga mellanrum:"
|
||||
},
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "Серія",
|
||||
"moveUp": "Посунути вгору",
|
||||
"moveDown": "Посунути вниз",
|
||||
"removeSeries": "Видалити послідовність"
|
||||
"removeSeries": "Видалити послідовність",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "{read} від {total} прочитано ({percentage}%)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "Можливі прогалини:"
|
||||
},
|
||||
|
||||
@@ -4095,7 +4095,11 @@
|
||||
"seriesPlaceholder": "系列",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "向下移动",
|
||||
"removeSeries": "删除系列"
|
||||
"removeSeries": "删除系列",
|
||||
"seriesIndexLabel": "Position in series",
|
||||
"totalLabel": "Books in series",
|
||||
"totalPlaceholder": "of",
|
||||
"totalHint": "Total books in this series. Shared with everyone who has this series."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -6194,6 +6198,10 @@
|
||||
"completion": {
|
||||
"readOfTotal": "{read} of {total} read ({percentage}%)"
|
||||
},
|
||||
"ownership": {
|
||||
"label": "In library",
|
||||
"ownedOfExpected": "{owned} of {expected} in library"
|
||||
},
|
||||
"gaps": {
|
||||
"label": "可能存在的差距:"
|
||||
},
|
||||
|
||||
@@ -117,6 +117,8 @@ export type BookSeriesMembership = {
|
||||
seriesName: string;
|
||||
seriesIndex: number | null;
|
||||
displayOrder: number;
|
||||
/** Series-level, shared by every book in the series and by every user. */
|
||||
expectedBookCount: number | null;
|
||||
};
|
||||
|
||||
export type BookCard = {
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface MetadataCandidate {
|
||||
isbn13?: string;
|
||||
seriesName?: string;
|
||||
seriesIndex?: number;
|
||||
/** Books the provider believes the series contains in total, not a field of this book. */
|
||||
seriesTotalBooks?: number;
|
||||
seriesMemberships?: MetadataSeriesMembership[];
|
||||
genres?: string[];
|
||||
coverUrl?: string;
|
||||
|
||||
@@ -24,6 +24,8 @@ export type SeriesDetail = {
|
||||
readCount: number;
|
||||
authors: string[];
|
||||
possibleGaps: number[];
|
||||
/** Total books a metadata provider reports for the series, or null when no provider has told us. */
|
||||
expectedBookCount: number | null;
|
||||
};
|
||||
|
||||
export type SeriesBooksPage = BooksPage & {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { DbModule } from '../db/db.module';
|
||||
import { SeriesExpectedCountService } from './services/series-expected-count.service';
|
||||
import { SeriesIdentityService } from './services/series-identity.service';
|
||||
import { SeriesMembershipService } from './services/series-membership.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [DbModule],
|
||||
providers: [SeriesIdentityService, SeriesMembershipService],
|
||||
exports: [SeriesIdentityService, SeriesMembershipService],
|
||||
providers: [SeriesIdentityService, SeriesMembershipService, SeriesExpectedCountService],
|
||||
exports: [SeriesIdentityService, SeriesMembershipService, SeriesExpectedCountService],
|
||||
})
|
||||
export class SeriesIdentityModule {}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { MetadataProviderKey, type MetadataCandidate } from '@bookorbit/types';
|
||||
|
||||
import { EMBEDDED_SERIES_TOTAL_SOURCE, MANUAL_SERIES_TOTAL_SOURCE, SeriesExpectedCountService } from './series-expected-count.service';
|
||||
import { normalizeMetadataText, normalizeMetadataTextKey } from '../utils/metadata-text-normalize.utils';
|
||||
|
||||
function makeSeriesIdentity(ids: Record<string, number>) {
|
||||
return {
|
||||
normalizeDisplayName: vi.fn((name: string | null | undefined) => normalizeMetadataText(name)),
|
||||
normalizeName: vi.fn((name: string | null | undefined) => normalizeMetadataTextKey(name)),
|
||||
findIdByName: vi.fn((name: string) => Promise.resolve(ids[normalizeMetadataTextKey(name) ?? ''] ?? null)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reports one updated row per update by default, matching a series whose count actually changed. */
|
||||
function makeDb(updatedRowsPerCall: number = 1) {
|
||||
const returning = vi.fn(() => Promise.resolve(Array.from({ length: updatedRowsPerCall }, (_, i) => ({ id: i + 1 }))));
|
||||
const where = vi.fn().mockReturnValue({ returning });
|
||||
const set = vi.fn().mockReturnValue({ where });
|
||||
const update = vi.fn().mockReturnValue({ set });
|
||||
return { db: { update, select: vi.fn() } as never, update, set, where, returning };
|
||||
}
|
||||
|
||||
/**
|
||||
* The raise-only rule lives in the SQL predicate so concurrent scans cannot race a read-then-write,
|
||||
* which means a mocked db never evaluates it. Pulling the operator tokens out of the Drizzle
|
||||
* expression is what lets us assert the comparison is `<` and not `<>`.
|
||||
*/
|
||||
function sqlOperators(node: unknown, out: string[] = []): string[] {
|
||||
if (node == null || typeof node !== 'object') return out;
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((child) => sqlOperators(child, out));
|
||||
return out;
|
||||
}
|
||||
const chunks = (node as { queryChunks?: unknown[] }).queryChunks;
|
||||
if (chunks) sqlOperators(chunks, out);
|
||||
const value = (node as { value?: unknown }).value;
|
||||
if (Array.isArray(value)) value.forEach((token) => typeof token === 'string' && out.push(token));
|
||||
return out;
|
||||
}
|
||||
|
||||
function candidate(data: Partial<MetadataCandidate>): MetadataCandidate {
|
||||
return {
|
||||
provider: MetadataProviderKey.HARDCOVER,
|
||||
providerId: 'p1',
|
||||
title: 'A book',
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SeriesExpectedCountService', () => {
|
||||
describe('recordFromCandidates', () => {
|
||||
it('stores the total against the matching local series', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101 });
|
||||
const { db, set, update } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
const updated = await service.recordFromCandidates([candidate({ seriesName: 'Dune', seriesTotalBooks: 6 })]);
|
||||
|
||||
expect(updated).toBe(1);
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectedBookCount: 6,
|
||||
expectedBookCountSource: MetadataProviderKey.HARDCOVER,
|
||||
expectedBookCountUpdatedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('never creates a series row for a series nobody owns', async () => {
|
||||
const identity = makeSeriesIdentity({});
|
||||
const { db, update } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
const updated = await service.recordFromCandidates([candidate({ seriesName: 'Unowned Saga', seriesTotalBooks: 9 })]);
|
||||
|
||||
expect(updated).toBe(0);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only raises the stored total, so the result cannot depend on scan order', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101 });
|
||||
const { db, where } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
await service.recordFromCandidates([candidate({ seriesName: 'Dune', seriesTotalBooks: 6 })]);
|
||||
|
||||
const operators = sqlOperators(where.mock.calls[0]?.[0]);
|
||||
expect(operators).toContain(' < ');
|
||||
expect(operators).not.toContain(' <> ');
|
||||
expect(operators).toContain(' is null');
|
||||
});
|
||||
|
||||
it('applies the same raise-only guard to totals read from a file', async () => {
|
||||
const identity = makeSeriesIdentity({ batman: 101 });
|
||||
const { db, where } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
await service.record('Batman', 7);
|
||||
|
||||
expect(sqlOperators(where.mock.calls[0]?.[0])).toContain(' < ');
|
||||
});
|
||||
|
||||
it('reports zero updates when the stored count already matches', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101 });
|
||||
const { db, update } = makeDb(0);
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
const updated = await service.recordFromCandidates([candidate({ seriesName: 'Dune', seriesTotalBooks: 6 })]);
|
||||
|
||||
expect(updated).toBe(0);
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips candidates with no series name', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101 });
|
||||
const { db, update } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
const updated = await service.recordFromCandidates([candidate({ seriesTotalBooks: 6 }), candidate({ seriesName: ' ', seriesTotalBooks: 6 })]);
|
||||
|
||||
expect(updated).toBe(0);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips candidates with no usable total', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101 });
|
||||
const { db, update } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
const updated = await service.recordFromCandidates([
|
||||
candidate({ seriesName: 'Dune' }),
|
||||
candidate({ seriesName: 'Dune', seriesTotalBooks: 0 }),
|
||||
candidate({ seriesName: 'Dune', seriesTotalBooks: -1 }),
|
||||
candidate({ seriesName: 'Dune', seriesTotalBooks: 10_001 }),
|
||||
candidate({ seriesName: 'Dune', seriesTotalBooks: 2.5 }),
|
||||
]);
|
||||
|
||||
expect(updated).toBe(0);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes once per series when providers disagree, taking the larger total', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101 });
|
||||
const { db, set, update } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
const updated = await service.recordFromCandidates([
|
||||
candidate({ provider: MetadataProviderKey.HARDCOVER, seriesName: 'Dune', seriesTotalBooks: 6 }),
|
||||
candidate({ provider: MetadataProviderKey.GOOGLE, seriesName: 'Dune', seriesTotalBooks: 8 }),
|
||||
candidate({ provider: MetadataProviderKey.OPEN_LIBRARY, seriesName: 'Dune', seriesTotalBooks: 3 }),
|
||||
]);
|
||||
|
||||
expect(updated).toBe(1);
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(set).toHaveBeenCalledWith(expect.objectContaining({ expectedBookCount: 8, expectedBookCountSource: MetadataProviderKey.GOOGLE }));
|
||||
});
|
||||
|
||||
it('collapses names that differ only by normalization into one series', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101 });
|
||||
const { db, update } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
await service.recordFromCandidates([
|
||||
candidate({ seriesName: 'Dune', seriesTotalBooks: 6 }),
|
||||
candidate({ seriesName: ' dune ', seriesTotalBooks: 6 }),
|
||||
candidate({ seriesName: 'DUNE', seriesTotalBooks: 6 }),
|
||||
]);
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('records each distinct series separately', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101, 'the expanse': 202 });
|
||||
const { db, update } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
const updated = await service.recordFromCandidates([
|
||||
candidate({ seriesName: 'Dune', seriesTotalBooks: 6 }),
|
||||
candidate({ seriesName: 'The Expanse', seriesTotalBooks: 9 }),
|
||||
]);
|
||||
|
||||
expect(updated).toBe(2);
|
||||
expect(update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does nothing for an empty candidate list', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101 });
|
||||
const { db, update } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
expect(await service.recordFromCandidates([])).toBe(0);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(identity.findIdByName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows database failures so a metadata refresh is never lost to a series total', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101 });
|
||||
const update = vi.fn(() => {
|
||||
throw new Error('connection terminated');
|
||||
});
|
||||
const service = new SeriesExpectedCountService({ update, select: vi.fn() } as never, identity as never);
|
||||
|
||||
await expect(service.recordFromCandidates([candidate({ seriesName: 'Dune', seriesTotalBooks: 6 })])).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('records a total a file declared about its own series', async () => {
|
||||
const identity = makeSeriesIdentity({ batman: 101 });
|
||||
const { db, set } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
const updated = await service.record('Batman', 7);
|
||||
|
||||
expect(updated).toBe(1);
|
||||
expect(set).toHaveBeenCalledWith(expect.objectContaining({ expectedBookCount: 7, expectedBookCountSource: EMBEDDED_SERIES_TOTAL_SOURCE }));
|
||||
});
|
||||
|
||||
it('applies the same validation to file totals as to provider totals', async () => {
|
||||
const identity = makeSeriesIdentity({ batman: 101 });
|
||||
const { db, update } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
for (const bad of [undefined, null, 0, -2, 4.5, 10_001, 'many']) {
|
||||
expect(await service.record('Batman', bad), `total=${String(bad)}`).toBe(0);
|
||||
}
|
||||
expect(await service.record(null, 7)).toBe(0);
|
||||
expect(await service.record(' ', 7)).toBe(0);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not create a series row for a file naming an unowned series', async () => {
|
||||
const identity = makeSeriesIdentity({});
|
||||
const { db, update } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
expect(await service.record('Unowned Comic', 7)).toBe(0);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows failures on the file path too', async () => {
|
||||
const identity = makeSeriesIdentity({ batman: 101 });
|
||||
const update = vi.fn(() => {
|
||||
throw new Error('connection terminated');
|
||||
});
|
||||
const service = new SeriesExpectedCountService({ update, select: vi.fn() } as never, identity as never);
|
||||
|
||||
await expect(service.record('Batman', 7)).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('swallows series lookup failures too, on the candidate path', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101 });
|
||||
identity.findIdByName.mockRejectedValueOnce(new Error('lookup exploded'));
|
||||
const { db } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
await expect(service.recordFromCandidates([candidate({ seriesName: 'Dune', seriesTotalBooks: 6 })])).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setManual', () => {
|
||||
it('stores what a person typed, marked as manual', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101 });
|
||||
const { db, set } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
await expect(service.setManual('Dune', 6)).resolves.toBe(true);
|
||||
expect(set).toHaveBeenCalledWith(expect.objectContaining({ expectedBookCount: 6, expectedBookCountSource: MANUAL_SERIES_TOTAL_SOURCE }));
|
||||
});
|
||||
|
||||
it('clears the total and its source when the field is emptied', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101 });
|
||||
const { db, set } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
await expect(service.setManual('Dune', null)).resolves.toBe(true);
|
||||
expect(set).toHaveBeenCalledWith(expect.objectContaining({ expectedBookCount: null, expectedBookCountSource: null }));
|
||||
});
|
||||
|
||||
it('lowers an existing total, unlike the automated paths which only raise it', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101 });
|
||||
const { db, set, where } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
await service.setManual('Dune', 2);
|
||||
|
||||
expect(set).toHaveBeenCalledWith(expect.objectContaining({ expectedBookCount: 2 }));
|
||||
// No comparison on the stored total: a person stating the length outright must win
|
||||
// in both directions, which is the escape hatch for a wrong automated maximum.
|
||||
const operators = sqlOperators(where.mock.calls[0]?.[0]);
|
||||
expect(operators).not.toContain(' < ');
|
||||
expect(operators).not.toContain(' <> ');
|
||||
});
|
||||
|
||||
it('reports false without writing when the series is unknown or unnamed', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101 });
|
||||
const { db, update } = makeDb();
|
||||
const service = new SeriesExpectedCountService(db, identity as never);
|
||||
|
||||
await expect(service.setManual('Never Heard Of It', 6)).resolves.toBe(false);
|
||||
await expect(service.setManual(null, 6)).resolves.toBe(false);
|
||||
await expect(service.setManual(' ', 6)).resolves.toBe(false);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates failures so a save cannot silently do nothing', async () => {
|
||||
const identity = makeSeriesIdentity({ dune: 101 });
|
||||
const update = vi.fn(() => {
|
||||
throw new Error('connection terminated');
|
||||
});
|
||||
const service = new SeriesExpectedCountService({ update, select: vi.fn() } as never, identity as never);
|
||||
|
||||
await expect(service.setManual('Dune', 6)).rejects.toThrow('connection terminated');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { and, eq, isNull, lt, or } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import type { MetadataCandidate } from '@bookorbit/types';
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { bookSeries } from '../../db/schema';
|
||||
import { sanitizeLogValue } from '../utils/log-sanitize.utils';
|
||||
import { normalizeSeriesTotalBooks } from '../utils/series-total-books.utils';
|
||||
import { SeriesIdentityService } from './series-identity.service';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
type ExpectedCountExecutor = Pick<Db, 'select' | 'update'>;
|
||||
|
||||
type PendingCount = {
|
||||
displayName: string;
|
||||
total: number;
|
||||
provider: string;
|
||||
};
|
||||
|
||||
type CountEntry = {
|
||||
seriesName: string | null | undefined;
|
||||
totalBooks: unknown;
|
||||
source: string;
|
||||
};
|
||||
|
||||
/** Source marker for a total the file declared itself, as opposed to a metadata provider. */
|
||||
export const EMBEDDED_SERIES_TOTAL_SOURCE = 'embedded';
|
||||
|
||||
/** Source marker for a total a person typed in the metadata editor. */
|
||||
export const MANUAL_SERIES_TOTAL_SOURCE = 'manual';
|
||||
|
||||
@Injectable()
|
||||
export class SeriesExpectedCountService {
|
||||
private readonly logger = new Logger(SeriesExpectedCountService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly seriesIdentity: SeriesIdentityService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Records how many books each provider believes a series holds. Only annotates series that
|
||||
* already exist locally, so browsing metadata for a series nobody owns never creates a row.
|
||||
*
|
||||
* Never throws: a series total is ancillary to the metadata refresh that produced it, and
|
||||
* failing to store one must not fail the refresh.
|
||||
*/
|
||||
async recordFromCandidates(candidates: readonly MetadataCandidate[], executor: ExpectedCountExecutor = this.db): Promise<number> {
|
||||
return this.recordEntries(
|
||||
candidates.map((candidate) => ({ seriesName: candidate.seriesName, totalBooks: candidate.seriesTotalBooks, source: candidate.provider })),
|
||||
executor,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a total a book file declared about its own series, as ComicInfo.xml Count does.
|
||||
* Shares every rule with the provider path: never creates an unowned series, and only raises
|
||||
* the stored total, so scanning a series whose files disagree settles on the same answer
|
||||
* whatever order the files happen to be read in.
|
||||
*/
|
||||
async record(
|
||||
seriesName: string | null | undefined,
|
||||
totalBooks: unknown,
|
||||
source: string = EMBEDDED_SERIES_TOTAL_SOURCE,
|
||||
executor: ExpectedCountExecutor = this.db,
|
||||
): Promise<number> {
|
||||
return this.recordEntries([{ seriesName, totalBooks, source }], executor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a total a person typed in the metadata editor. Three things separate it from the
|
||||
* automated paths: null clears the value, a smaller total still wins because the person is
|
||||
* stating the length outright, and failures propagate so a save cannot silently do nothing.
|
||||
*
|
||||
* The row is shared, so this changes the total every user sees for the series.
|
||||
*/
|
||||
async setManual(
|
||||
seriesName: string | null | undefined,
|
||||
expectedBookCount: number | null,
|
||||
executor: ExpectedCountExecutor = this.db,
|
||||
): Promise<boolean> {
|
||||
const displayName = this.seriesIdentity.normalizeDisplayName(seriesName);
|
||||
if (!displayName) return false;
|
||||
|
||||
const seriesId = await this.seriesIdentity.findIdByName(displayName, executor);
|
||||
if (seriesId == null) return false;
|
||||
|
||||
const total = expectedBookCount === null ? null : (normalizeSeriesTotalBooks(expectedBookCount) ?? null);
|
||||
await executor
|
||||
.update(bookSeries)
|
||||
.set({
|
||||
expectedBookCount: total,
|
||||
expectedBookCountSource: total === null ? null : MANUAL_SERIES_TOTAL_SOURCE,
|
||||
expectedBookCountUpdatedAt: new Date(),
|
||||
})
|
||||
.where(eq(bookSeries.id, seriesId));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async recordEntries(entries: readonly CountEntry[], executor: ExpectedCountExecutor): Promise<number> {
|
||||
try {
|
||||
return await this.applyEntries(entries, executor);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
this.logger.warn(
|
||||
`[series.expectedCount] [fail] errorClass=${e.name} error="${sanitizeLogValue(e.message)}" - could not record series totals; metadata refresh unaffected`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async applyEntries(entries: readonly CountEntry[], executor: ExpectedCountExecutor): Promise<number> {
|
||||
const pending = this.collectPendingCounts(entries);
|
||||
if (pending.size === 0) return 0;
|
||||
|
||||
let updated = 0;
|
||||
for (const entry of pending.values()) {
|
||||
const seriesId = await this.seriesIdentity.findIdByName(entry.displayName, executor);
|
||||
if (seriesId == null) continue;
|
||||
|
||||
// Raise-only, evaluated in SQL so concurrent scans cannot race a read-then-write.
|
||||
// Without it the stored total depends on which file or provider was seen last: two comics
|
||||
// in one series tagged with different Counts would flip the value on every rescan.
|
||||
// Equality is excluded by the same comparison, so no-op writes still never churn updatedAt.
|
||||
const rows = await executor
|
||||
.update(bookSeries)
|
||||
.set({
|
||||
expectedBookCount: entry.total,
|
||||
expectedBookCountSource: entry.provider,
|
||||
expectedBookCountUpdatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(bookSeries.id, seriesId), or(isNull(bookSeries.expectedBookCount), lt(bookSeries.expectedBookCount, entry.total))))
|
||||
.returning({ id: bookSeries.id });
|
||||
|
||||
updated += rows.length;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed by normalized name so two providers naming the same series differently still collapse.
|
||||
* The larger total wins, both here within a batch and against the stored value in
|
||||
* {@link applyEntries}: the source counting novellas and companion volumes is usually the
|
||||
* complete picture, undercounting silently hides the gaps this feature exists to surface, and
|
||||
* taking the maximum is the only rule that makes the result independent of scan order.
|
||||
* {@link setManual} is the escape hatch when a person needs to lower or clear it.
|
||||
*/
|
||||
private collectPendingCounts(entries: readonly CountEntry[]): Map<string, PendingCount> {
|
||||
const pending = new Map<string, PendingCount>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const total = normalizeSeriesTotalBooks(entry.totalBooks);
|
||||
if (total === undefined) continue;
|
||||
|
||||
const displayName = this.seriesIdentity.normalizeDisplayName(entry.seriesName);
|
||||
const key = this.seriesIdentity.normalizeName(displayName);
|
||||
if (!displayName || !key) continue;
|
||||
|
||||
const existing = pending.get(key);
|
||||
if (!existing || total > existing.total) {
|
||||
pending.set(key, { displayName, total, provider: entry.source });
|
||||
}
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
@@ -62,8 +62,8 @@ describe('SeriesMembershipService', () => {
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ seriesId: 101, seriesName: 'Dune', seriesIndex: 1, displayOrder: 0 },
|
||||
{ seriesId: 202, seriesName: 'The Expanse', seriesIndex: 5, displayOrder: 1 },
|
||||
{ seriesId: 101, seriesName: 'Dune', seriesIndex: 1, displayOrder: 0, expectedBookCount: null },
|
||||
{ seriesId: 202, seriesName: 'The Expanse', seriesIndex: 5, displayOrder: 1, expectedBookCount: null },
|
||||
]);
|
||||
expect(deleteFrom).toHaveBeenCalledTimes(1);
|
||||
expect(identity.resolveSeriesId).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -22,6 +22,7 @@ export type SeriesMembershipRef = {
|
||||
seriesName: string;
|
||||
seriesIndex: number | null;
|
||||
displayOrder: number;
|
||||
expectedBookCount: number | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -110,6 +111,7 @@ export class SeriesMembershipService {
|
||||
seriesName: bookSeries.name,
|
||||
seriesIndex: bookSeriesMemberships.seriesIndex,
|
||||
displayOrder: bookSeriesMemberships.displayOrder,
|
||||
expectedBookCount: bookSeries.expectedBookCount,
|
||||
})
|
||||
.from(bookSeriesMemberships)
|
||||
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
|
||||
@@ -123,6 +125,7 @@ export class SeriesMembershipService {
|
||||
seriesName: row.seriesName,
|
||||
seriesIndex: row.seriesIndex,
|
||||
displayOrder: row.displayOrder,
|
||||
expectedBookCount: row.expectedBookCount ?? null,
|
||||
});
|
||||
result.set(row.bookId, list);
|
||||
}
|
||||
@@ -184,6 +187,7 @@ export class SeriesMembershipService {
|
||||
seriesName: displayName,
|
||||
seriesIndex: membership.seriesIndex ?? null,
|
||||
displayOrder: normalized.length,
|
||||
expectedBookCount: null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { MAX_SERIES_TOTAL_BOOKS, normalizeSeriesTotalBooks } from './series-total-books.utils';
|
||||
|
||||
describe('normalizeSeriesTotalBooks', () => {
|
||||
it('accepts positive integers', () => {
|
||||
expect(normalizeSeriesTotalBooks(1)).toBe(1);
|
||||
expect(normalizeSeriesTotalBooks(7)).toBe(7);
|
||||
expect(normalizeSeriesTotalBooks(MAX_SERIES_TOTAL_BOOKS)).toBe(MAX_SERIES_TOTAL_BOOKS);
|
||||
});
|
||||
|
||||
it('accepts numeric strings because provider payloads are untyped JSON', () => {
|
||||
expect(normalizeSeriesTotalBooks('7')).toBe(7);
|
||||
expect(normalizeSeriesTotalBooks(' 12 ')).toBe(12);
|
||||
});
|
||||
|
||||
it('rejects counts below one', () => {
|
||||
expect(normalizeSeriesTotalBooks(0)).toBeUndefined();
|
||||
expect(normalizeSeriesTotalBooks(-3)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects counts above the ceiling so a bad payload cannot widen gap detection', () => {
|
||||
expect(normalizeSeriesTotalBooks(MAX_SERIES_TOTAL_BOOKS + 1)).toBeUndefined();
|
||||
expect(normalizeSeriesTotalBooks(Number.MAX_SAFE_INTEGER)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects non-integers', () => {
|
||||
expect(normalizeSeriesTotalBooks(3.5)).toBeUndefined();
|
||||
expect(normalizeSeriesTotalBooks(Number.NaN)).toBeUndefined();
|
||||
expect(normalizeSeriesTotalBooks(Number.POSITIVE_INFINITY)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects absent and non-numeric values', () => {
|
||||
expect(normalizeSeriesTotalBooks(undefined)).toBeUndefined();
|
||||
expect(normalizeSeriesTotalBooks(null)).toBeUndefined();
|
||||
expect(normalizeSeriesTotalBooks('')).toBeUndefined();
|
||||
expect(normalizeSeriesTotalBooks('many')).toBeUndefined();
|
||||
expect(normalizeSeriesTotalBooks(true)).toBeUndefined();
|
||||
expect(normalizeSeriesTotalBooks({})).toBeUndefined();
|
||||
expect(normalizeSeriesTotalBooks([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Upper bound for a provider-reported series length. Matches the ceiling the series gap
|
||||
* finder already applies, so a nonsense total can never widen gap detection past it.
|
||||
*/
|
||||
export const MAX_SERIES_TOTAL_BOOKS = 10_000;
|
||||
|
||||
/**
|
||||
* Providers report series length as untrusted JSON: missing, null, floats, negatives and
|
||||
* numeric strings all occur. Returns undefined for anything that is not a usable count.
|
||||
*/
|
||||
export function normalizeSeriesTotalBooks(value: unknown): number | undefined {
|
||||
const numeric = typeof value === 'string' ? Number(value.trim()) : value;
|
||||
if (typeof numeric !== 'number' || !Number.isInteger(numeric)) return undefined;
|
||||
if (numeric < 1 || numeric > MAX_SERIES_TOTAL_BOOKS) return undefined;
|
||||
return numeric;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "book_series" ADD COLUMN "expected_book_count" integer;--> statement-breakpoint
|
||||
ALTER TABLE "book_series" ADD COLUMN "expected_book_count_source" varchar(50);--> statement-breakpoint
|
||||
ALTER TABLE "book_series" ADD COLUMN "expected_book_count_updated_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "book_series" ADD CONSTRAINT "book_series_expected_book_count_range_chk" CHECK ("book_series"."expected_book_count" IS NULL OR ("book_series"."expected_book_count" >= 1 AND "book_series"."expected_book_count" <= 10000));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -435,6 +435,13 @@
|
||||
"when": 1785695009216,
|
||||
"tag": "0061_add_server_fonts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 62,
|
||||
"version": "7",
|
||||
"when": 1785712608966,
|
||||
"tag": "0062_add_series_expected_book_count",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ export const bookSeries = pgTable(
|
||||
id: serial('id').primaryKey(),
|
||||
name: varchar('name', { length: 500 }).notNull(),
|
||||
normalizedName: varchar('normalized_name', { length: 500 }).notNull(),
|
||||
expectedBookCount: integer('expected_book_count'),
|
||||
expectedBookCountSource: varchar('expected_book_count_source', { length: 50 }),
|
||||
expectedBookCountUpdatedAt: timestamp('expected_book_count_updated_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
@@ -20,6 +23,12 @@ export const bookSeries = pgTable(
|
||||
index('book_series_name_trgm_idx').using('gin', t.name.op('gin_trgm_ops')),
|
||||
index('book_series_name_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.name}) gin_trgm_ops`),
|
||||
index('book_series_name_lower_idx').on(sql`lower(${t.name})`),
|
||||
// Ceiling mirrors MAX_SERIES_TOTAL_BOOKS; a provider cannot persist a total that would
|
||||
// make the series gap finder enumerate an unbounded range.
|
||||
check(
|
||||
'book_series_expected_book_count_range_chk',
|
||||
sql`${t.expectedBookCount} IS NULL OR (${t.expectedBookCount} >= 1 AND ${t.expectedBookCount} <= 10000)`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -605,6 +605,7 @@ export class BookRepository {
|
||||
seriesName: string;
|
||||
seriesIndex: number | null;
|
||||
displayOrder: number;
|
||||
expectedBookCount: number | null;
|
||||
}[],
|
||||
};
|
||||
}
|
||||
@@ -644,6 +645,7 @@ export class BookRepository {
|
||||
seriesName: bookSeries.name,
|
||||
seriesIndex: bookSeriesMemberships.seriesIndex,
|
||||
displayOrder: bookSeriesMemberships.displayOrder,
|
||||
expectedBookCount: bookSeries.expectedBookCount,
|
||||
})
|
||||
.from(bookSeriesMemberships)
|
||||
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
|
||||
@@ -783,7 +785,14 @@ export class BookRepository {
|
||||
updatedAt: Date;
|
||||
}[];
|
||||
narratorRows: { bookId: number; name: string }[];
|
||||
seriesMembershipRows: { bookId: number; seriesId: number; seriesName: string; seriesIndex: number | null; displayOrder: number }[];
|
||||
seriesMembershipRows: {
|
||||
bookId: number;
|
||||
seriesId: number;
|
||||
seriesName: string;
|
||||
seriesIndex: number | null;
|
||||
displayOrder: number;
|
||||
expectedBookCount: number | null;
|
||||
}[];
|
||||
total: number;
|
||||
}> {
|
||||
const { where, sort, limit, offset, userId } = opts;
|
||||
@@ -1339,6 +1348,7 @@ export class BookRepository {
|
||||
seriesName: bookSeries.name,
|
||||
seriesIndex: bookSeriesMemberships.seriesIndex,
|
||||
displayOrder: bookSeriesMemberships.displayOrder,
|
||||
expectedBookCount: bookSeries.expectedBookCount,
|
||||
})
|
||||
.from(bookSeriesMemberships)
|
||||
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
|
||||
|
||||
@@ -19,6 +19,7 @@ import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { normalizeMetadataText, normalizeMetadataTextKey } from '../../common/utils/metadata-text-normalize.utils';
|
||||
import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils';
|
||||
import { formatSeriesIndex } from '../../common/utils/series-index-format.utils';
|
||||
import { SeriesExpectedCountService } from '../../common/services/series-expected-count.service';
|
||||
import { SeriesMembershipService } from '../../common/services/series-membership.service';
|
||||
import { isDateKey, resolveTimeZone, toDateKeyInTimeZone, toTimeZoneStartOfDay } from '../../common/utils/timezone.utils';
|
||||
import { extractEpubMetadata } from '../metadata/lib/epub';
|
||||
@@ -265,6 +266,7 @@ export class BookService {
|
||||
@Optional() private readonly fileRenameService: FileRenameService,
|
||||
@Optional() private readonly achievementEvents: AchievementEventsService,
|
||||
@Optional() private readonly seriesMemberships?: SeriesMembershipService,
|
||||
@Optional() private readonly seriesExpectedCount?: SeriesExpectedCountService,
|
||||
) {
|
||||
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
|
||||
}
|
||||
@@ -1709,6 +1711,11 @@ export class BookService {
|
||||
|
||||
if (dto.seriesMemberships !== undefined) {
|
||||
await this.seriesMemberships?.replaceForBook(id, dto.seriesMemberships, tx);
|
||||
// After replaceForBook, because it is what creates any series row the editor named.
|
||||
for (const membership of dto.seriesMemberships ?? []) {
|
||||
if (membership.expectedBookCount === undefined) continue;
|
||||
await this.seriesExpectedCount?.setManual(membership.seriesName, membership.expectedBookCount ?? null, tx);
|
||||
}
|
||||
}
|
||||
this.throwIfMetadataUpdateFailpoint('afterSeriesMembershipsReplace');
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IsArray, IsBoolean, IsIn, IsInt, IsNumber, IsOptional, IsString, Matches, Max, MaxLength, Min, ValidateNested } from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { MetadataProviderKey } from '@bookorbit/types';
|
||||
import { MAX_SERIES_TOTAL_BOOKS } from '../../../common/utils/series-total-books.utils';
|
||||
import { CustomMetadataValueDto } from '../../custom-metadata/dto/custom-metadata-value.dto';
|
||||
|
||||
export class AudiobookChapterDto {
|
||||
@@ -33,6 +34,9 @@ export class ComicMetadataDto {
|
||||
export class BookSeriesMembershipDto {
|
||||
@IsString() @MaxLength(500) seriesName!: string;
|
||||
@IsOptional() @IsNumber() seriesIndex?: number | null;
|
||||
// Series-level rather than book-level: writing it changes the total for every book in the
|
||||
// series and for every user. Bounds mirror the book_series range constraint.
|
||||
@IsOptional() @IsInt() @Min(1) @Max(MAX_SERIES_TOTAL_BOOKS) expectedBookCount?: number | null;
|
||||
}
|
||||
|
||||
export class CommunityRatingDto {
|
||||
|
||||
@@ -51,7 +51,14 @@ 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 SeriesMembershipRow = {
|
||||
bookId: number;
|
||||
seriesId: number;
|
||||
seriesName: string;
|
||||
seriesIndex: number | null;
|
||||
displayOrder: number;
|
||||
expectedBookCount?: number | null;
|
||||
};
|
||||
type ProgressRow = { bookFileId: number; percentage: number | null };
|
||||
type StatusRow = {
|
||||
bookId: number;
|
||||
@@ -195,6 +202,7 @@ export function assembleBookCards(
|
||||
seriesName: membership.seriesName,
|
||||
seriesIndex: membership.seriesIndex,
|
||||
displayOrder: membership.displayOrder,
|
||||
expectedBookCount: membership.expectedBookCount ?? null,
|
||||
})),
|
||||
authors: authorsByBook.get(row.id) ?? [],
|
||||
files,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@bookorbit/types';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { SeriesExpectedCountService } from '../../common/services/series-expected-count.service';
|
||||
import { MetadataPreferenceResolver } from '../metadata-preferences/metadata-preference-resolver';
|
||||
import { MetadataPreferencesService } from '../metadata-preferences/metadata-preferences.service';
|
||||
import { ProviderConfigService } from '../metadata-preferences/provider-config.service';
|
||||
@@ -50,6 +51,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
let resolver: Mocked<MetadataPreferenceResolver>;
|
||||
let registry: Mocked<ProviderRegistry>;
|
||||
let throttleTracker: Mocked<Pick<ProviderThrottleTracker, 'isThrottled'>>;
|
||||
let seriesExpectedCount: Mocked<Pick<SeriesExpectedCountService, 'recordFromCandidates'>>;
|
||||
let pipeline: MetadataFetchPipeline;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -76,6 +78,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
} as unknown as Mocked<ProviderRegistry>;
|
||||
|
||||
throttleTracker = { isThrottled: vi.fn().mockReturnValue(false) };
|
||||
seriesExpectedCount = { recordFromCandidates: vi.fn().mockResolvedValue(0) };
|
||||
pipeline = new MetadataFetchPipeline(
|
||||
fetchService,
|
||||
preferencesService,
|
||||
@@ -83,6 +86,7 @@ describe('MetadataFetchPipeline', () => {
|
||||
registry,
|
||||
throttleTracker as ProviderThrottleTracker,
|
||||
providerConfig,
|
||||
seriesExpectedCount as unknown as SeriesExpectedCountService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -116,6 +120,44 @@ describe('MetadataFetchPipeline', () => {
|
||||
expect(fetchService.search).toHaveBeenCalledWith({ title: 'Query' }, [MetadataProviderKey.GOOGLE, MetadataProviderKey.OPEN_LIBRARY]);
|
||||
});
|
||||
|
||||
describe('series expected counts', () => {
|
||||
function primePreferences() {
|
||||
const global = createPreferences();
|
||||
preferencesService.getGlobal.mockResolvedValue(global);
|
||||
resolver.resolve.mockReturnValue(global);
|
||||
resolver.withForwardCompatibility.mockReturnValue(global);
|
||||
registry.all.mockReturnValue([{ key: MetadataProviderKey.GOOGLE }, { key: MetadataProviderKey.OPEN_LIBRARY }] as never);
|
||||
}
|
||||
|
||||
it('forwards every candidate, not just the one that wins field resolution', async () => {
|
||||
primePreferences();
|
||||
fetchService.search.mockReturnValue(
|
||||
of(
|
||||
candidate(MetadataProviderKey.GOOGLE, 'g1', { seriesName: 'Dune', seriesTotalBooks: 6 }),
|
||||
candidate(MetadataProviderKey.OPEN_LIBRARY, 'o1', { seriesName: 'Dune', seriesTotalBooks: 8 }),
|
||||
),
|
||||
);
|
||||
|
||||
await pipeline.run({ title: 'Query' }, {});
|
||||
|
||||
expect(seriesExpectedCount.recordFromCandidates).toHaveBeenCalledTimes(1);
|
||||
expect(seriesExpectedCount.recordFromCandidates).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ providerId: 'g1', seriesTotalBooks: 6 }),
|
||||
expect.objectContaining({ providerId: 'o1', seriesTotalBooks: 8 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('still resolves fields when recording the totals fails', async () => {
|
||||
primePreferences();
|
||||
seriesExpectedCount.recordFromCandidates.mockRejectedValueOnce(new Error('unreachable'));
|
||||
fetchService.search.mockReturnValue(of(candidate(MetadataProviderKey.GOOGLE, 'g1', { title: 'Fetched Title' })));
|
||||
|
||||
const resolved = await pipeline.run({ title: 'Query' }, {});
|
||||
|
||||
expect(resolved.title).toBe('Fetched Title');
|
||||
});
|
||||
});
|
||||
|
||||
it('filters derived provider keys by enabled provider config', async () => {
|
||||
const global = createPreferences((fields) => {
|
||||
fields.title = {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { firstValueFrom, toArray } from 'rxjs';
|
||||
import { MetadataPreferenceResolver } from '../metadata-preferences/metadata-preference-resolver';
|
||||
import { ProviderConfigService } from '../metadata-preferences/provider-config.service';
|
||||
import { MetadataPreferencesService } from '../metadata-preferences/metadata-preferences.service';
|
||||
import { SeriesExpectedCountService } from '../../common/services/series-expected-count.service';
|
||||
import { createGenreBlocklistTokenSet, filterGenresAgainstBlocklist } from '../../common/utils/genre-blocklist.utils';
|
||||
import { normalizeMetadataText, normalizeMetadataTextKey } from '../../common/utils/metadata-text-normalize.utils';
|
||||
import { MetadataFetchService } from './metadata-fetch.service';
|
||||
@@ -80,6 +81,7 @@ export class MetadataFetchPipeline {
|
||||
private readonly registry: ProviderRegistry,
|
||||
private readonly throttleTracker: ProviderThrottleTracker,
|
||||
private readonly providerConfig: ProviderConfigService,
|
||||
private readonly seriesExpectedCount: SeriesExpectedCountService,
|
||||
) {}
|
||||
|
||||
async run(
|
||||
@@ -133,6 +135,12 @@ export class MetadataFetchPipeline {
|
||||
})
|
||||
: [];
|
||||
|
||||
// The service already swallows its own failures; this guards the invariant at the boundary so
|
||||
// that a future change there can never turn a series total into a failed metadata refresh.
|
||||
await this.seriesExpectedCount.recordFromCandidates(candidates).catch((err: Error) => {
|
||||
this.logger.warn(`[metadata.seriesTotals] [fail] errorClass=${err.name} - series totals not recorded; resolution continues`);
|
||||
});
|
||||
|
||||
const byProvider = new Map<string, MetadataCandidate>();
|
||||
for (const c of candidates) {
|
||||
if (!byProvider.has(c.provider)) byProvider.set(c.provider, c);
|
||||
|
||||
@@ -88,6 +88,7 @@ export class AmazonProvider implements IdentifiableProvider {
|
||||
pageCount: data.pageCount,
|
||||
seriesName: data.seriesName,
|
||||
seriesIndex: data.seriesIndex,
|
||||
seriesTotalBooks: data.seriesTotalBooks,
|
||||
coverUrl: data.coverUrl,
|
||||
genres: data.tags?.length ? data.tags : undefined,
|
||||
sourceUrl: url.toString(),
|
||||
|
||||
@@ -103,6 +103,52 @@ describe('AmazonScraper', () => {
|
||||
expect(result.pageCount).toBe(1234);
|
||||
});
|
||||
|
||||
describe('series attributes', () => {
|
||||
function seriesHtml(label: string, name = 'The Expanse'): string {
|
||||
return `
|
||||
<span id="productTitle">A Book</span>
|
||||
<div id="rpi-attribute-book_details-series">
|
||||
<span class="rpi-attribute-label"><span>${label}</span></span>
|
||||
<span class="rpi-attribute-value"><a href="/dp/X"><span>${name}</span></a></span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
it('reads the series name, position and length from one label', () => {
|
||||
const result = parseBookPage(seriesHtml('Book 3 of 7'));
|
||||
|
||||
expect(result.seriesName).toBe('The Expanse');
|
||||
expect(result.seriesIndex).toBe(3);
|
||||
expect(result.seriesTotalBooks).toBe(7);
|
||||
});
|
||||
|
||||
it('keeps a fractional position while still reading the length', () => {
|
||||
const result = parseBookPage(seriesHtml('Book 2.5 of 9'));
|
||||
|
||||
expect(result.seriesIndex).toBe(2.5);
|
||||
expect(result.seriesTotalBooks).toBe(9);
|
||||
});
|
||||
|
||||
it('still yields the position when the label states no length', () => {
|
||||
const result = parseBookPage(seriesHtml('Book 3 of'));
|
||||
|
||||
expect(result.seriesIndex).toBe(3);
|
||||
expect(result.seriesTotalBooks).toBeUndefined();
|
||||
});
|
||||
|
||||
it('yields nothing when the page has no series block', () => {
|
||||
const result = parseBookPage('<span id="productTitle">A Book</span>');
|
||||
|
||||
expect(result.seriesName).toBeUndefined();
|
||||
expect(result.seriesIndex).toBeUndefined();
|
||||
expect(result.seriesTotalBooks).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a length beyond the supported ceiling', () => {
|
||||
expect(parseBookPage(seriesHtml('Book 1 of 10001')).seriesTotalBooks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a negative rating value', () => {
|
||||
const html = `
|
||||
<span id="productTitle">Bad Rating Book</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as cheerio from 'cheerio';
|
||||
import type { CheerioAPI } from 'cheerio';
|
||||
|
||||
import { parsePublishedDateKey, parsePublishedYear, publishedYearFromDateKey } from '../../../../common/utils/published-date.utils';
|
||||
import { normalizeSeriesTotalBooks } from '../../../../common/utils/series-total-books.utils';
|
||||
|
||||
export interface AmazonBookData {
|
||||
title?: string;
|
||||
@@ -17,6 +18,7 @@ export interface AmazonBookData {
|
||||
pageCount?: number;
|
||||
seriesName?: string;
|
||||
seriesIndex?: number;
|
||||
seriesTotalBooks?: number;
|
||||
coverUrl?: string;
|
||||
tags?: string[];
|
||||
communityRating?: number;
|
||||
@@ -43,6 +45,7 @@ export function parseBookPage(html: string): AmazonBookData {
|
||||
pageCount: extractPageCount($),
|
||||
seriesName: extractSeriesName($),
|
||||
seriesIndex: extractSeriesIndex($),
|
||||
seriesTotalBooks: extractSeriesTotalBooks($),
|
||||
coverUrl: extractCoverUrl($),
|
||||
tags: extractCategories($),
|
||||
communityRating: extractCommunityRating($),
|
||||
@@ -219,14 +222,26 @@ function extractSeriesName($: CheerioAPI): string | undefined {
|
||||
return $('#rpi-attribute-book_details-series .rpi-attribute-value a span').first().text().trim() || undefined;
|
||||
}
|
||||
|
||||
function seriesLabel($: CheerioAPI): string {
|
||||
return $('#rpi-attribute-book_details-series .rpi-attribute-label span').first().text();
|
||||
}
|
||||
|
||||
function extractSeriesIndex($: CheerioAPI): number | undefined {
|
||||
const label = $('#rpi-attribute-book_details-series .rpi-attribute-label span').first().text();
|
||||
const match = label.match(/book\s+(\d+(?:\.\d+)?)\s+of/i);
|
||||
const match = seriesLabel($).match(/book\s+(\d+(?:\.\d+)?)\s+of/i);
|
||||
if (!match) return undefined;
|
||||
const n = parseFloat(match[1]);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same "Book 3 of 7" label that yields the index also states the series length. Optional
|
||||
* trailing group so a label that stops after "of" simply yields no total.
|
||||
*/
|
||||
function extractSeriesTotalBooks($: CheerioAPI): number | undefined {
|
||||
const match = seriesLabel($).match(/book\s+\d+(?:\.\d+)?\s+of\s+(\d+)/i);
|
||||
return match ? normalizeSeriesTotalBooks(match[1]) : undefined;
|
||||
}
|
||||
|
||||
function extractCoverUrl($: CheerioAPI): string | undefined {
|
||||
const img = $('#landingImage, #imgBlkFront').first();
|
||||
|
||||
|
||||
@@ -87,6 +87,23 @@ describe('mapIssueToCandidate', () => {
|
||||
expect(withoutName.subtitle).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('series total books', () => {
|
||||
it('takes the issue count from the volume the caller searched', () => {
|
||||
const candidate = mapIssueToCandidate(makeIssue(), { volumeIssueCount: 50 });
|
||||
expect(candidate.seriesTotalBooks).toBe(50);
|
||||
});
|
||||
|
||||
it('stays undefined when no volume record was available, as on a general issue search', () => {
|
||||
expect(mapIssueToCandidate(makeIssue()).seriesTotalBooks).toBeUndefined();
|
||||
expect(mapIssueToCandidate(makeIssue(), {}).seriesTotalBooks).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects an out-of-range issue count', () => {
|
||||
expect(mapIssueToCandidate(makeIssue(), { volumeIssueCount: 0 }).seriesTotalBooks).toBeUndefined();
|
||||
expect(mapIssueToCandidate(makeIssue(), { volumeIssueCount: 10_001 }).seriesTotalBooks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('title', () => {
|
||||
it('uses the issue name verbatim, not a series/issue-number concatenation', () => {
|
||||
const candidate = mapIssueToCandidate(makeIssue({ name: 'The Origin', volume: { id: 10, name: 'Series Name' }, issue_number: '12.5' }));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ComicMetadataFields, MetadataCandidate, MetadataProviderKey } from '@bookorbit/types';
|
||||
|
||||
import { parsePublishedDateKey, parsePublishedYear, publishedYearFromDateKey } from '../../../../common/utils/published-date.utils';
|
||||
import { normalizeSeriesTotalBooks } from '../../../../common/utils/series-total-books.utils';
|
||||
import { ComicVineIssue, ComicVinePersonCredit } from './comicvine.types';
|
||||
|
||||
function parseYear(dateStr: string | null | undefined): number | undefined {
|
||||
@@ -44,7 +45,12 @@ function buildDisplayTitle(issue: ComicVineIssue): string {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function mapIssueToCandidate(issue: ComicVineIssue): MetadataCandidate {
|
||||
/**
|
||||
* The issue payload only carries its volume's id and name, so the issue count has to come from
|
||||
* the volume record the caller already searched. Options object rather than a positional count so
|
||||
* that `.map(mapIssueToCandidate)` cannot silently pass the array index as the total.
|
||||
*/
|
||||
export function mapIssueToCandidate(issue: ComicVineIssue, options?: { volumeIssueCount?: number }): MetadataCandidate {
|
||||
const writers = extractByRole(issue.person_credits ?? [], 'writer');
|
||||
const rawPublishedDate = issue.cover_date ?? issue.store_date;
|
||||
const publishedDate = parsePublishedDateKey(rawPublishedDate ?? undefined);
|
||||
@@ -60,6 +66,7 @@ export function mapIssueToCandidate(issue: ComicVineIssue): MetadataCandidate {
|
||||
publishedYear: publishedDate ? publishedYearFromDateKey(publishedDate) : parseYear(rawPublishedDate),
|
||||
seriesName: issue.volume.name,
|
||||
seriesIndex: parseSeriesIndex(issue.issue_number),
|
||||
seriesTotalBooks: normalizeSeriesTotalBooks(options?.volumeIssueCount),
|
||||
coverUrl: issue.image?.original_url,
|
||||
sourceUrl: issue.site_detail_url ?? undefined,
|
||||
comicMetadata: buildComicMetadata(issue),
|
||||
|
||||
@@ -137,7 +137,7 @@ export class ComicVineProvider implements IdentifiableProvider {
|
||||
: await this.client.searchIssuesInVolume(volume.id, issueNumber, apiKey);
|
||||
if (issues.length > 0) {
|
||||
const enriched = await Promise.all(issues.slice(0, maxCandidates).map((issue) => this.enrichWithDetails(issue, apiKey, signal)));
|
||||
return enriched.map(mapIssueToCandidate);
|
||||
return enriched.map((issue) => mapIssueToCandidate(issue, { volumeIssueCount: volume.count_of_issues }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ export class ComicVineProvider implements IdentifiableProvider {
|
||||
private async generalSearch(query: string, apiKey: string, maxCandidates: number, signal?: AbortSignal): Promise<MetadataCandidate[]> {
|
||||
const issues = signal ? await this.client.searchIssues(query, apiKey, signal) : await this.client.searchIssues(query, apiKey);
|
||||
const enriched = await Promise.all(issues.slice(0, maxCandidates).map((issue) => this.enrichWithDetails(issue, apiKey, signal)));
|
||||
return enriched.map(mapIssueToCandidate);
|
||||
return enriched.map((issue) => mapIssueToCandidate(issue));
|
||||
}
|
||||
|
||||
private async enrichWithDetails(issue: ComicVineIssue, apiKey: string, signal?: AbortSignal): Promise<ComicVineIssue> {
|
||||
|
||||
@@ -166,6 +166,7 @@ describe('mapBookWithEditions', () => {
|
||||
isbn13: '9780756404079',
|
||||
seriesName: 'The Kingkiller Chronicle',
|
||||
seriesIndex: 1,
|
||||
seriesTotalBooks: 3,
|
||||
communityRating: 4.42,
|
||||
communityRatingCount: 12345,
|
||||
coverUrl: 'https://assets.hardcover.app/edition-cover.jpg',
|
||||
@@ -178,6 +179,37 @@ describe('mapBookWithEditions', () => {
|
||||
expect(mapBookWithEditions(book)).toEqual([]);
|
||||
});
|
||||
|
||||
describe('series total books', () => {
|
||||
function withBooksCount(booksCount: unknown): HardcoverBookWithEditions {
|
||||
return {
|
||||
...baseBook,
|
||||
featured_book_series: {
|
||||
series: { name: 'The Kingkiller Chronicle', books_count: booksCount as number | undefined },
|
||||
position: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('maps the series book count Hardcover already returns', () => {
|
||||
expect(mapBookWithEditions(withBooksCount(7))[0].seriesTotalBooks).toBe(7);
|
||||
});
|
||||
|
||||
it('is undefined when Hardcover omits the count', () => {
|
||||
expect(mapBookWithEditions(withBooksCount(undefined))[0].seriesTotalBooks).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is undefined when the series itself is absent', () => {
|
||||
const book: HardcoverBookWithEditions = { ...baseBook, featured_book_series: undefined };
|
||||
expect(mapBookWithEditions(book)[0].seriesTotalBooks).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects an out-of-range count rather than trusting the payload', () => {
|
||||
expect(mapBookWithEditions(withBooksCount(0))[0].seriesTotalBooks).toBeUndefined();
|
||||
expect(mapBookWithEditions(withBooksCount(-4))[0].seriesTotalBooks).toBeUndefined();
|
||||
expect(mapBookWithEditions(withBooksCount(10_001))[0].seriesTotalBooks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array when editions is empty', () => {
|
||||
const book: HardcoverBookWithEditions = { ...baseBook, editions: [] };
|
||||
expect(mapBookWithEditions(book)).toEqual([]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MetadataCandidate, MetadataProviderKey } from '@bookorbit/types';
|
||||
|
||||
import { parsePublishedDateKey, parsePublishedYear, publishedYearFromDateKey } from '../../../../common/utils/published-date.utils';
|
||||
import { normalizeSeriesTotalBooks } from '../../../../common/utils/series-total-books.utils';
|
||||
import { HardcoverBookWithEditions, HardcoverCachedContributor, HardcoverEdition, HardcoverSearchDocument } from './hardcover.types';
|
||||
|
||||
function parseYear(releaseYear: number | undefined | null, releaseDate: string | undefined): number | undefined {
|
||||
@@ -111,6 +112,7 @@ function mapEdition(edition: HardcoverEdition, book: HardcoverBookWithEditions):
|
||||
isbn13: edition.isbn_13,
|
||||
seriesName: book.featured_book_series?.series?.name,
|
||||
seriesIndex: book.featured_book_series?.position ?? undefined,
|
||||
seriesTotalBooks: normalizeSeriesTotalBooks(book.featured_book_series?.series?.books_count),
|
||||
coverUrl: edition.image?.url ?? book.image?.url,
|
||||
sourceUrl: `https://hardcover.app/books/${book.slug}`,
|
||||
...(communityRating !== undefined ? { communityRating } : {}),
|
||||
|
||||
@@ -279,6 +279,31 @@ describe('mapRanobeDbBook', () => {
|
||||
expect(result?.genres).toEqual([]);
|
||||
});
|
||||
|
||||
describe('series total books', () => {
|
||||
function seriesOf(bookIds: number[]): RanobeDbBook {
|
||||
return {
|
||||
...baseBook,
|
||||
series: {
|
||||
...baseBook.series!,
|
||||
books: bookIds.map((id) => ({ id, lang: null, title: null, romaji: null, image: null })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('counts the roster RanobeDB already returns for the index calculation', () => {
|
||||
expect(mapRanobeDbBook(seriesOf([1, 2, 3, 4]))?.seriesTotalBooks).toBe(4);
|
||||
});
|
||||
|
||||
it('is undefined when the book has no series', () => {
|
||||
const book: RanobeDbBook = { ...baseBook, series: null };
|
||||
expect(mapRanobeDbBook(book)?.seriesTotalBooks).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is undefined for an empty roster rather than reporting a zero-length series', () => {
|
||||
expect(mapRanobeDbBook(seriesOf([]))?.seriesTotalBooks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no coverUrl when image is null', () => {
|
||||
const book: RanobeDbBook = { ...baseBook, image: null };
|
||||
const result = mapRanobeDbBook(book);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MetadataCandidate, MetadataProviderKey } from '@bookorbit/types';
|
||||
|
||||
import { parseCompactPublishedDate, publishedYearFromDateKey } from '../../../../common/utils/published-date.utils';
|
||||
import { normalizeSeriesTotalBooks } from '../../../../common/utils/series-total-books.utils';
|
||||
import { RanobeDbBook, RanobeDbRelease } from './ranobedb.types';
|
||||
|
||||
const IMAGE_BASE_URL = 'https://images.ranobedb.org';
|
||||
@@ -127,6 +128,7 @@ export function mapRanobeDbBook(book: RanobeDbBook): MetadataCandidate | null {
|
||||
isbn13: selectedRelease?.isbn13 ?? undefined,
|
||||
seriesName: book.series?.title,
|
||||
seriesIndex: resolveSeriesIndex(book),
|
||||
seriesTotalBooks: normalizeSeriesTotalBooks(book.series?.books.length),
|
||||
description: book.description?.trim() || book.description_ja?.trim() || undefined,
|
||||
genres: resolveGenres(book),
|
||||
coverUrl: book.image ? `${IMAGE_BASE_URL}/${book.image.filename}` : undefined,
|
||||
|
||||
@@ -40,6 +40,7 @@ export class ComicFormatExtractor implements FormatExtractor {
|
||||
language: comicMetadata?.language ?? null,
|
||||
seriesName: comicMetadata?.seriesName ?? null,
|
||||
seriesIndex: comicMetadata?.seriesIndex ?? null,
|
||||
seriesTotalBooks: comicMetadata?.seriesTotalBooks ?? null,
|
||||
authors: comicMetadata?.authors ?? [],
|
||||
genres: comicMetadata?.genres?.length ? comicMetadata.genres : (comicMetadata?.tags ?? []),
|
||||
googleBooksId: comicMetadata?.googleBooksId ?? null,
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface ParsedBookData {
|
||||
language?: string | null;
|
||||
seriesName?: string | null;
|
||||
seriesIndex?: number | null;
|
||||
/** Series length declared by the file itself; annotates the series, never the book row. */
|
||||
seriesTotalBooks?: number | null;
|
||||
authors: { name: string; sortName: string | null }[];
|
||||
genres: string[];
|
||||
tags?: string[];
|
||||
|
||||
@@ -128,6 +128,32 @@ describe('extractCbzMetadata', () => {
|
||||
expect(r?.seriesIndex).toBe(5);
|
||||
});
|
||||
|
||||
it('extracts the series length from Count', async () => {
|
||||
const xml = `<ComicInfo><Series>Amazing Series</Series><Number>5</Number><Count>12</Count></ComicInfo>`;
|
||||
mockZipFile(buildZipWithComicInfo(xml));
|
||||
|
||||
const r = await extractCbzMetadata('/book.cbz');
|
||||
expect(r?.seriesTotalBooks).toBe(12);
|
||||
});
|
||||
|
||||
it('leaves the series length unset when Count is absent', async () => {
|
||||
const xml = `<ComicInfo><Series>Amazing Series</Series><Number>5</Number></ComicInfo>`;
|
||||
mockZipFile(buildZipWithComicInfo(xml));
|
||||
|
||||
const r = await extractCbzMetadata('/book.cbz');
|
||||
expect(r?.seriesTotalBooks).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a Count that is empty, unparseable or out of range', async () => {
|
||||
for (const count of ['', 'lots', '0', '-3', '4.5', '10001']) {
|
||||
const xml = `<ComicInfo><Series>S</Series><Count>${count}</Count></ComicInfo>`;
|
||||
mockZipFile(buildZipWithComicInfo(xml));
|
||||
|
||||
const r = await extractCbzMetadata('/book.cbz');
|
||||
expect(r?.seriesTotalBooks, `Count=${count}`).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts authors from Writer field', async () => {
|
||||
const xml = `<ComicInfo><Writer>Alan Moore</Writer></ComicInfo>`;
|
||||
mockZipFile(buildZipWithComicInfo(xml));
|
||||
@@ -347,6 +373,28 @@ describe('extractCbzMetadata', () => {
|
||||
expect(r?.publisher).toBe('Image');
|
||||
});
|
||||
|
||||
it('extracts the series length from numberOfIssues', async () => {
|
||||
const comment = JSON.stringify({
|
||||
'ComicBookInfo/1.0': { series: 'JSON Series', issue: 3, numberOfIssues: 24, credits: [], tags: [] },
|
||||
});
|
||||
mockZipFile(buildZipCommentOnly(comment));
|
||||
|
||||
const r = await extractCbzMetadata('/book.cbz');
|
||||
expect(r?.seriesTotalBooks).toBe(24);
|
||||
});
|
||||
|
||||
it('leaves the series length unset when numberOfIssues is absent or unusable', async () => {
|
||||
for (const value of [undefined, null, 0, -1, 'many']) {
|
||||
const comment = JSON.stringify({
|
||||
'ComicBookInfo/1.0': { series: 'JSON Series', numberOfIssues: value, credits: [], tags: [] },
|
||||
});
|
||||
mockZipFile(buildZipCommentOnly(comment));
|
||||
|
||||
const r = await extractCbzMetadata('/book.cbz');
|
||||
expect(r?.seriesTotalBooks, `numberOfIssues=${String(value)}`).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts Writer credits as authors', async () => {
|
||||
const comment = JSON.stringify({
|
||||
'ComicBookInfo/1.0': {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createExtractorFromData, UnrarError } from 'node-unrar-js';
|
||||
import { extractCbzZipEntry, isSupportedCbzZipCompression, readCbzZipIndex } from '../../../common/cbz-zip-reader';
|
||||
import { getSevenZip } from '../../../common/sevenzip';
|
||||
import { parsePublishedDateKey, parsePublishedYear } from '../../../common/utils/published-date.utils';
|
||||
import { normalizeSeriesTotalBooks } from '../../../common/utils/series-total-books.utils';
|
||||
import { cleanupSevenZipArtifacts, createSevenZipTempId, type SevenZipInstance } from './sevenzip-vfs';
|
||||
|
||||
export interface ParsedCbzComicMetadata {
|
||||
@@ -25,6 +26,8 @@ export interface ParsedCbzMetadata {
|
||||
subtitle: string | null;
|
||||
seriesName: string | null;
|
||||
seriesIndex: number | null;
|
||||
/** Total books the tagger recorded for the series, from ComicInfo Count or ComicBookInfo numberOfIssues. */
|
||||
seriesTotalBooks: number | null;
|
||||
description: string | null;
|
||||
publisher: string | null;
|
||||
publishedDate: string | null;
|
||||
@@ -199,6 +202,7 @@ function parseComicInfoXml(xmlBuf: Buffer): ParsedCbzMetadata | null {
|
||||
subtitle: managedNotes.get('subtitle') ?? null,
|
||||
seriesName: str('Series'),
|
||||
seriesIndex: num('Number'),
|
||||
seriesTotalBooks: normalizeSeriesTotalBooks(num('Count')) ?? null,
|
||||
description: str('Summary') ?? str('Description'),
|
||||
publisher: str('Publisher'),
|
||||
publishedDate,
|
||||
@@ -270,6 +274,7 @@ function parseComicBookInfoJson(comment: string): ParsedCbzMetadata | null {
|
||||
subtitle: null,
|
||||
seriesName: (cbi['series'] as string) ?? null,
|
||||
seriesIndex: cbi['issue'] != null ? (Number.isFinite(Number(cbi['issue'])) ? Number(cbi['issue']) : null) : null,
|
||||
seriesTotalBooks: normalizeSeriesTotalBooks(cbi['numberOfIssues']) ?? null,
|
||||
description: (cbi['comments'] as string) ?? null,
|
||||
publisher: (cbi['publisher'] as string) ?? null,
|
||||
publishedDate,
|
||||
|
||||
@@ -86,7 +86,7 @@ import { mkdir, readFile, readdir, rm, writeFile } from 'fs/promises';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { authors, bookAuthors, bookGenres, bookMetadata, books, bookTags, genres, tags } from '../../db/schema';
|
||||
import { extractCbzMetadata } from './lib/cbz-metadata';
|
||||
import { extractCbzMetadata, type ParsedCbzMetadata } from './lib/cbz-metadata';
|
||||
import { generateThumbnail, imageExt } from './lib/cover';
|
||||
import { extractCbzCover } from './lib/cover-cbz';
|
||||
import { extractEpubCover } from './lib/cover-epub';
|
||||
@@ -216,6 +216,7 @@ describe('MetadataService', () => {
|
||||
filterAutomatedBookUpdate: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
embedder?: { embedBook: ReturnType<typeof vi.fn> } | null;
|
||||
seriesExpectedCount?: { record: ReturnType<typeof vi.fn> };
|
||||
},
|
||||
) {
|
||||
return new MetadataService(
|
||||
@@ -231,6 +232,9 @@ describe('MetadataService', () => {
|
||||
}) as never,
|
||||
(overrides?.embedder ?? embedder) as never,
|
||||
metadataEvents as never,
|
||||
undefined,
|
||||
undefined,
|
||||
overrides?.seriesExpectedCount as never,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -637,6 +641,7 @@ describe('MetadataService', () => {
|
||||
subtitle: null,
|
||||
seriesName: 'Amazing Series',
|
||||
seriesIndex: 1,
|
||||
seriesTotalBooks: null,
|
||||
description: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
@@ -673,6 +678,78 @@ describe('MetadataService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('extractAndSave(cbz) series length from ComicInfo Count', () => {
|
||||
function comicMetadata(overrides: Partial<ParsedCbzMetadata> = {}): ParsedCbzMetadata {
|
||||
return {
|
||||
title: 'Amazing Series',
|
||||
subtitle: null,
|
||||
seriesName: 'Amazing Series',
|
||||
seriesIndex: 1,
|
||||
seriesTotalBooks: null,
|
||||
description: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
pageCount: null,
|
||||
rating: null,
|
||||
isbn10: null,
|
||||
isbn13: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
googleBooksId: null,
|
||||
goodreadsId: null,
|
||||
amazonId: null,
|
||||
hardcoverId: null,
|
||||
hardcoverEditionId: null,
|
||||
openLibraryId: null,
|
||||
ranobedbId: null,
|
||||
koboId: null,
|
||||
comicvineId: null,
|
||||
lubimyczytacId: null,
|
||||
aladinId: null,
|
||||
itunesId: null,
|
||||
comicMetadata: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function scanWith(metadata: ParsedCbzMetadata) {
|
||||
const { db } = makeDb();
|
||||
const seriesExpectedCount = { record: vi.fn().mockResolvedValue(1) };
|
||||
const service = makeService(db, undefined, { seriesExpectedCount });
|
||||
vi.spyOn(service, 'replaceAuthors').mockResolvedValue(undefined);
|
||||
vi.spyOn(service, 'replaceGenres').mockResolvedValue(undefined);
|
||||
mockExtractCbzMetadata.mockResolvedValue(metadata);
|
||||
|
||||
await service.extractAndSave(24, '/tmp/book.cbz', 'cbz');
|
||||
return seriesExpectedCount;
|
||||
}
|
||||
|
||||
it('records the series length the file declared', async () => {
|
||||
const seriesExpectedCount = await scanWith(comicMetadata({ seriesTotalBooks: 12 }));
|
||||
|
||||
expect(seriesExpectedCount.record).toHaveBeenCalledWith('Amazing Series', 12);
|
||||
});
|
||||
|
||||
it('passes the absent length through so no series is annotated', async () => {
|
||||
const seriesExpectedCount = await scanWith(comicMetadata({ seriesTotalBooks: null }));
|
||||
|
||||
expect(seriesExpectedCount.record).toHaveBeenCalledWith('Amazing Series', null);
|
||||
});
|
||||
|
||||
it('scans without error when the expected-count service is not provided', async () => {
|
||||
const { db } = makeDb();
|
||||
const service = makeService(db);
|
||||
vi.spyOn(service, 'replaceAuthors').mockResolvedValue(undefined);
|
||||
vi.spyOn(service, 'replaceGenres').mockResolvedValue(undefined);
|
||||
mockExtractCbzMetadata.mockResolvedValue(comicMetadata({ seriesTotalBooks: 12 }));
|
||||
|
||||
await expect(service.extractAndSave(25, '/tmp/book.cbz', 'cbz')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('extractAndSave(mobi) ignores malformed publishedDate values from providers', async () => {
|
||||
const { db, updateSet } = makeDb();
|
||||
const service = makeService(db);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../../common/utils/metadata-text-normalize.utils';
|
||||
import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils';
|
||||
import { SeriesIdentityService } from '../../common/services/series-identity.service';
|
||||
import { SeriesExpectedCountService } from '../../common/services/series-expected-count.service';
|
||||
import { SeriesMembershipService } from '../../common/services/series-membership.service';
|
||||
import { refreshPrimaryAuthorSortNamesForAuthors, refreshPrimaryAuthorSortNamesForBooks } from '../../db/book-author-sort-key';
|
||||
import { BookEmbedderService } from '../embedding/book-embedder.service';
|
||||
@@ -77,6 +78,7 @@ export class MetadataService {
|
||||
@Optional() private readonly metadataEvents?: MetadataEventsService,
|
||||
@Optional() private readonly seriesIdentity?: SeriesIdentityService,
|
||||
@Optional() private readonly seriesMemberships?: SeriesMembershipService,
|
||||
@Optional() private readonly seriesExpectedCount?: SeriesExpectedCountService,
|
||||
) {
|
||||
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
|
||||
}
|
||||
@@ -718,6 +720,10 @@ export class MetadataService {
|
||||
await this.comicMetadataRepository.upsert(bookId, filtered.comicMetadata);
|
||||
}
|
||||
|
||||
// ComicInfo Count describes the series the file names, so it is read from the parsed file
|
||||
// rather than the lock-filtered patch: a locked seriesName still leaves the count truthful.
|
||||
await this.seriesExpectedCount?.record(data.seriesName, data.seriesTotalBooks);
|
||||
|
||||
this.logger.debug(
|
||||
`[metadata.persist_book] [end] bookId=${bookId} format=${format} title="${sanitizeLogValue(data.title ?? '')}" - book metadata persisted`,
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ type SeriesDetailRow = {
|
||||
name: string;
|
||||
bookCount: number;
|
||||
readCount: number;
|
||||
expectedBookCount: number | null;
|
||||
authors: string[];
|
||||
indices: number[];
|
||||
};
|
||||
@@ -192,6 +193,7 @@ export class SeriesRepository {
|
||||
.select({
|
||||
id: bookSeries.id,
|
||||
name: bookSeries.name,
|
||||
expectedBookCount: bookSeries.expectedBookCount,
|
||||
bookCount: sql<number>`count(distinct ${books.id})::int`,
|
||||
readCount: sql<number>`count(distinct CASE WHEN ${userBookStatus.status} = 'read' THEN ${books.id} END)::int`,
|
||||
})
|
||||
@@ -201,7 +203,7 @@ export class SeriesRepository {
|
||||
.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);
|
||||
.groupBy(bookSeries.id, bookSeries.name, bookSeries.expectedBookCount);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
@@ -225,6 +227,7 @@ export class SeriesRepository {
|
||||
name: row.name,
|
||||
bookCount: row.bookCount,
|
||||
readCount: row.readCount,
|
||||
expectedBookCount: row.expectedBookCount ?? null,
|
||||
authors: authorsMap.get(params.seriesId) ?? [],
|
||||
indices,
|
||||
};
|
||||
|
||||
@@ -368,4 +368,86 @@ describe('SeriesService', () => {
|
||||
expect(result.seriesInfo.possibleGaps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeGaps with a provider expected book count', () => {
|
||||
beforeEach(() => {
|
||||
seriesRepo.findBookIds.mockResolvedValue({ bookIds: [], total: 0 });
|
||||
});
|
||||
|
||||
async function gapsFor(indices: number[], bookCount: number, expectedBookCount: number | null) {
|
||||
seriesRepo.findDetail.mockResolvedValue({ id: 42, name: 'S', bookCount, readCount: 0, authors: [], indices, expectedBookCount });
|
||||
const result = await service.findBooks(reqUser(), 42, {});
|
||||
return result.seriesInfo.possibleGaps;
|
||||
}
|
||||
|
||||
it('reports books past the highest owned index, which is the whole point of the total', async () => {
|
||||
expect(await gapsFor([1, 2, 4], 3, 7)).toEqual([3, 5, 6, 7]);
|
||||
});
|
||||
|
||||
it('reports the books below the lowest owned index', async () => {
|
||||
expect(await gapsFor([4], 1, 5)).toEqual([1, 2, 3, 5]);
|
||||
});
|
||||
|
||||
it('reports no gaps for a complete series', async () => {
|
||||
expect(await gapsFor([1, 2, 3], 3, 3)).toEqual([]);
|
||||
});
|
||||
|
||||
it('still reports gaps for a single owned book, which the interior-only rule cannot', async () => {
|
||||
expect(await gapsFor([2], 1, 3)).toEqual([1, 3]);
|
||||
expect(await gapsFor([2], 1, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('exposes the expected count on the series payload', async () => {
|
||||
seriesRepo.findDetail.mockResolvedValue({
|
||||
id: 42,
|
||||
name: 'S',
|
||||
bookCount: 1,
|
||||
readCount: 0,
|
||||
authors: [],
|
||||
indices: [1],
|
||||
expectedBookCount: 7,
|
||||
});
|
||||
const result = await service.findBooks(reqUser(), 42, {});
|
||||
expect(result.seriesInfo.expectedBookCount).toBe(7);
|
||||
});
|
||||
|
||||
it('reports null when no provider has supplied a total', async () => {
|
||||
seriesRepo.findDetail.mockResolvedValue({ id: 42, name: 'S', bookCount: 1, readCount: 0, authors: [], indices: [1], expectedBookCount: null });
|
||||
const result = await service.findBooks(reqUser(), 42, {});
|
||||
expect(result.seriesInfo.expectedBookCount).toBeNull();
|
||||
});
|
||||
|
||||
describe('distrusting the total rather than naming a book missing wrongly', () => {
|
||||
it('falls back to interior gaps when a book has no series index', async () => {
|
||||
// Four books but only three numbered: the unnumbered one could be any of #4 to #7.
|
||||
expect(await gapsFor([1, 2, 5], 4, 7)).toEqual([3, 4]);
|
||||
});
|
||||
|
||||
it('falls back to interior gaps when a book has a fractional index', async () => {
|
||||
expect(await gapsFor([1, 2.5, 4], 3, 7)).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it('falls back when an owned book is numbered past the provider total', async () => {
|
||||
expect(await gapsFor([1, 2, 9], 3, 7)).toEqual([3, 4, 5, 6, 7, 8]);
|
||||
});
|
||||
|
||||
it('ignores a total of zero or below', async () => {
|
||||
expect(await gapsFor([1, 3], 2, 0)).toEqual([2]);
|
||||
expect(await gapsFor([1, 3], 2, -5)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('ignores a total beyond the ceiling so gap enumeration stays bounded', async () => {
|
||||
expect(await gapsFor([1, 3], 2, 10_001)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('ignores a fractional total', async () => {
|
||||
expect(await gapsFor([1, 3], 2, 4.5)).toEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
it('counts duplicate editions of one entry as a single owned position', async () => {
|
||||
// Two files for #1 plus #3: bookCount 3 matches the three index rows, so the total is trusted.
|
||||
expect(await gapsFor([1, 1, 3], 3, 4)).toEqual([2, 4]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
|
||||
import type { BooksPage, SeriesBooksPage, SeriesDetail, SeriesPage, SeriesSummary } from '@bookorbit/types';
|
||||
import { MAX_OFFSET_ROWS, isOffsetWithinLimit } from '../../common/constants/pagination.constants';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { MAX_SERIES_TOTAL_BOOKS, normalizeSeriesTotalBooks } from '../../common/utils/series-total-books.utils';
|
||||
import { assembleBookCards } from '../book/utils/assemble-book-cards';
|
||||
import { BookReadService } from '../book/book-read.service';
|
||||
import { LibraryService } from '../library/library.service';
|
||||
@@ -114,6 +115,7 @@ export class SeriesService {
|
||||
readCount: 0,
|
||||
authors: existsInAnyLibrary.authors,
|
||||
possibleGaps: [],
|
||||
expectedBookCount: existsInAnyLibrary.expectedBookCount ?? null,
|
||||
};
|
||||
return { items: [], total: 0, page, size, seriesInfo: emptyInfo };
|
||||
}
|
||||
@@ -121,7 +123,7 @@ export class SeriesService {
|
||||
throw new NotFoundException('Series not found');
|
||||
}
|
||||
|
||||
const possibleGaps = this.computeGaps(detail.indices);
|
||||
const possibleGaps = this.computeGaps(detail.indices, detail.bookCount, detail.expectedBookCount);
|
||||
|
||||
let items: BooksPage['items'] = [];
|
||||
if (bookPage.bookIds.length > 0) {
|
||||
@@ -160,24 +162,58 @@ export class SeriesService {
|
||||
readCount: detail.readCount,
|
||||
authors: detail.authors,
|
||||
possibleGaps,
|
||||
expectedBookCount: detail.expectedBookCount ?? null,
|
||||
};
|
||||
|
||||
return { items, total: bookPage.total, page, size, seriesInfo };
|
||||
}
|
||||
|
||||
private computeGaps(indices: number[]): number[] {
|
||||
private computeGaps(indices: number[], bookCount: number, expectedBookCount: number | null): number[] {
|
||||
const integerIndices = indices.filter((idx) => Math.abs(Math.round(idx) - idx) < 0.01).map((idx) => Math.round(idx));
|
||||
|
||||
if (integerIndices.length < 2) return [];
|
||||
if (integerIndices.length === 0) return [];
|
||||
|
||||
const min = Math.min(...integerIndices);
|
||||
const max = Math.max(...integerIndices);
|
||||
if (min < 1 || max > MAX_SERIES_TOTAL_BOOKS) return [];
|
||||
|
||||
if (min < 1 || max > 10_000) return [];
|
||||
const expectedMax = this.resolveTrustedExpectedMax(indices, bookCount, integerIndices, expectedBookCount);
|
||||
|
||||
// With no trusted total only interior holes are knowable, and one book has no interior.
|
||||
if (expectedMax === undefined) {
|
||||
if (integerIndices.length < 2) return [];
|
||||
return this.collectMissing(integerIndices, min, max);
|
||||
}
|
||||
|
||||
return this.collectMissing(integerIndices, 1, expectedMax);
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider total turns "the books you own" into "the books the series has", which is the whole
|
||||
* point, but it also lets us name books as missing. Only trust it when nothing about the local
|
||||
* data contradicts it, because a false "you are missing #5" is worse than staying quiet.
|
||||
*/
|
||||
private resolveTrustedExpectedMax(
|
||||
indices: number[],
|
||||
bookCount: number,
|
||||
integerIndices: number[],
|
||||
expectedBookCount: number | null,
|
||||
): number | undefined {
|
||||
const expected = normalizeSeriesTotalBooks(expectedBookCount);
|
||||
if (expected === undefined) return undefined;
|
||||
|
||||
// An owned book with no usable index would be reported missing, so every book must be numbered.
|
||||
if (indices.length !== bookCount || integerIndices.length !== indices.length) return undefined;
|
||||
|
||||
// Owning a book numbered past the total means the total is stale or matched the wrong series.
|
||||
if (Math.max(...integerIndices) > expected) return undefined;
|
||||
|
||||
return expected;
|
||||
}
|
||||
|
||||
private collectMissing(integerIndices: number[], from: number, to: number): number[] {
|
||||
const present = new Set(integerIndices);
|
||||
const gaps: number[] = [];
|
||||
for (let i = min; i <= max; i++) {
|
||||
for (let i = from; i <= to; i++) {
|
||||
if (!present.has(i)) {
|
||||
gaps.push(i);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user