Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions fe/src/__tests__/CollectionView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1284,4 +1284,53 @@ describe('CollectionView', () => {
'Updated the author image, description, related authors, and catalog.',
)
})

it('renders the per-collection series position for a book matched via a non-primary membership', async () => {
const pinia = createPinia()
setActivePinia(pinia)

const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '<div />' } },
{ path: '/collection/:type/:name', name: 'collection', component: CollectionView },
],
})
await router.push('/collection/series/Mistborn')
await router.isReady().catch(() => {})

const store = useLibraryStore()
store.audiobooks = [
{
id: 1,
title: 'Shadows of Self',
authors: ['Brandon Sanderson'],
// Primary series (and number) point at a DIFFERENT series...
series: 'Other Series',
seriesNumber: '5',
// ...but the book also belongs to Mistborn at position 2.
seriesMemberships: [
{ seriesName: 'Other Series', seriesNumber: '5', isPrimary: true },
{ seriesName: 'Mistborn', seriesNumber: '2', isPrimary: false },
],
imageUrl: 'c1.jpg',
files: [],
},
] as unknown as import('@/types').Audiobook[]

store.fetchLibrary = vi.fn(async () => undefined)
const wrapper = mount(CollectionView, {
global: {
plugins: [pinia, router],
stubs: ['EditAudiobookModal', 'CustomSelect', 'AddLibraryModal'],
},
})
await flushPromises()

const card = wrapper.find('.collection-card')
expect(card.exists()).toBe(true)
// Shows the Mistborn position (#2 from the membership), not the primary 'Other Series' #5.
expect(card.text()).toContain('#2')
expect(card.text()).not.toContain('#5')
})
})
48 changes: 48 additions & 0 deletions fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,54 @@ describe('EditAudiobookModal move options', () => {
)
})

it('persists a non-first primary series selection (regression for #658)', async () => {
const wrapper = mount(EditAudiobookModal, {
props: { isOpen: true, audiobook },
attachTo: document.body,
global: { plugins: [(await import('pinia')).createPinia()] },
})

await new Promise((r) => setTimeout(r, 200))

const vm = wrapper.vm as unknown as {
formData: {
seriesMemberships: Array<{ seriesName: string; seriesNumber: string; isPrimary: boolean }>
}
handleSave: () => Promise<void>
}

// User marks the SECOND series as primary; the bug previously reverted this to the first.
vm.formData.seriesMemberships = [
{ seriesName: 'Publication Order', seriesNumber: '1', isPrimary: false },
{ seriesName: 'Chronological Order', seriesNumber: '3', isPrimary: true },
]
await wrapper.vm.$nextTick()

await vm.handleSave()
await new Promise((r) => setTimeout(r, 50))

const { apiService } = await import('@/services/api')
expect(apiService.updateAudiobook).toHaveBeenCalledWith(
1,
expect.objectContaining({
series: 'Chronological Order',
seriesNumber: '3',
seriesMemberships: [
expect.objectContaining({
seriesName: 'Publication Order',
isPrimary: false,
sortOrder: 0,
}),
expect.objectContaining({
seriesName: 'Chronological Order',
isPrimary: true,
sortOrder: 1,
}),
],
}),
)
})

it('hydrates current metadata immediately and renders person fields as tags', async () => {
const { apiService } = await import('@/services/api')
vi.mocked(apiService.getQualityProfiles).mockImplementation(() => new Promise(() => {}))
Expand Down
35 changes: 35 additions & 0 deletions fe/src/__tests__/seriesUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { formatSeriesMemberships } from '@/utils/seriesUtils'

describe('formatSeriesMemberships', () => {
it('lists every series a book belongs to with its number', () => {
const result = formatSeriesMemberships({
series: 'Publication Order',
seriesNumber: '1',
seriesMemberships: [
{ seriesName: 'Publication Order', seriesNumber: '1', isPrimary: true, sortOrder: 0 },
{ seriesName: 'Chronological Order', seriesNumber: '3', isPrimary: false, sortOrder: 1 },
],
})
expect(result).toBe('Publication Order #1, Chronological Order #3')
})

it('omits the number when a membership has none', () => {
const result = formatSeriesMemberships({
seriesMemberships: [{ seriesName: 'Standalone Saga', isPrimary: true, sortOrder: 0 }],
})
expect(result).toBe('Standalone Saga')
})

it('falls back to the legacy single series when there are no memberships', () => {
expect(formatSeriesMemberships({ series: 'Solo Series', seriesNumber: '2' })).toBe(
'Solo Series #2',
)
expect(formatSeriesMemberships({ series: 'No Number' })).toBe('No Number')
})

it('returns an empty string when there is no series information', () => {
expect(formatSeriesMemberships({})).toBe('')
expect(formatSeriesMemberships({ seriesMemberships: [] })).toBe('')
})
})
10 changes: 4 additions & 6 deletions fe/src/components/domain/audiobook/AudiobookDetailsModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,12 @@
</div>
</div>

<div v-if="book.series || book.genres?.length" class="detail-section">
<div v-if="formatSeriesMemberships(book) || book.genres?.length" class="detail-section">
<h4>Series & Genre Information</h4>
<div class="detail-grid">
<div v-if="book.series" class="detail-item">
<div v-if="formatSeriesMemberships(book)" class="detail-item">
<span class="label">Series:</span>
<span class="value"
>{{ book.series
}}<span v-if="book.seriesNumber"> #{{ book.seriesNumber }}</span></span
>
<span class="value">{{ formatSeriesMemberships(book) }}</span>
</div>
<div v-if="book.genres?.length" class="detail-item">
<span class="label">Genres:</span>
Expand Down Expand Up @@ -193,6 +190,7 @@ import { stripHtmlAndNormalize } from '@/utils/textUtils'
import { useProtectedImages } from '@/composables/useProtectedImages'
import { Modal, ModalBody, ModalHeader } from '@/components/feedback'
import { formatDate, formatRuntime, capitalizeFirst } from '@/utils/searchResultFormatting'
import { formatSeriesMemberships } from '@/utils/seriesUtils'

interface Props {
visible: boolean
Expand Down
10 changes: 4 additions & 6 deletions fe/src/components/domain/audiobook/AudiobookModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,12 @@
</div>
</div>

<div v-if="book.series || book.genres?.length" class="detail-section">
<div v-if="formatSeriesMemberships(book) || book.genres?.length" class="detail-section">
<h4>Series & Genre Information</h4>
<div class="detail-grid">
<div v-if="book.series" class="detail-item">
<div v-if="formatSeriesMemberships(book)" class="detail-item">
<span class="label">Series:</span>
<span class="value"
>{{ book.series
}}<span v-if="book.seriesNumber"> #{{ book.seriesNumber }}</span></span
>
<span class="value">{{ formatSeriesMemberships(book) }}</span>
</div>
<div v-if="book.genres?.length" class="detail-item">
<span class="label">Genres:</span>
Expand Down Expand Up @@ -193,6 +190,7 @@ import { stripHtmlAndNormalize } from '@/utils/textUtils'
import { useProtectedImages } from '@/composables/useProtectedImages'
import { Modal, ModalBody, ModalHeader } from '@/components/feedback'
import { formatDate, formatRuntime, capitalizeFirst } from '@/utils/searchResultFormatting'
import { formatSeriesMemberships } from '@/utils/seriesUtils'

interface Props {
visible: boolean
Expand Down
4 changes: 2 additions & 2 deletions fe/src/components/domain/audiobook/EditAudiobookModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,7 @@ function serializeSeriesMembershipRows(
seriesName: membership.seriesName,
seriesNumber: membership.seriesNumber,
seriesAsin: normalizeOptionalText(membership.seriesAsin),
isPrimary: Boolean(membership.isPrimary || index === 0),
isPrimary: Boolean(membership.isPrimary),
sortOrder: index,
}))

Expand Down Expand Up @@ -1678,7 +1678,7 @@ async function handleSave() {
seriesName: membership.seriesName,
seriesNumber: membership.seriesNumber || undefined,
seriesAsin: membership.seriesAsin || undefined,
isPrimary: Boolean(membership.isPrimary || index === 0),
isPrimary: Boolean(membership.isPrimary),
sortOrder: index,
})),
genres: normalizeStringList(formData.value.genres),
Expand Down
45 changes: 45 additions & 0 deletions fe/src/utils/seriesUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Listenarr - Audiobook Management System
* Copyright (C) 2024-2026 Listenarr Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Audiobook, AudiobookSeriesMembership } from '@/types'

type SeriesBearer = Pick<Audiobook, 'series' | 'seriesNumber' | 'seriesMemberships'>

/**
* All series a book belongs to, formatted for display (e.g. "Publication Order #1,
* Chronological Order #3"). Uses every series membership so a multi-series book shows all of
* them; falls back to the legacy single series/number when no memberships are present.
*/
export function formatSeriesMemberships(book: SeriesBearer): string {
const memberships = book.seriesMemberships
if (memberships && memberships.length > 0) {
const parts = memberships.map(formatMembership).filter(Boolean)
if (parts.length > 0) return parts.join(', ')
}

const legacyName = (book.series || '').trim()
if (!legacyName) return ''
const legacyNumber = (book.seriesNumber || '').trim()
return legacyNumber ? `${legacyName} #${legacyNumber}` : legacyName
}

function formatMembership(membership: AudiobookSeriesMembership): string {
const name = (membership.seriesName || '').trim()
if (!name) return ''
const number = (membership.seriesNumber || '').trim()
return number ? `${name} #${number}` : name
}
42 changes: 34 additions & 8 deletions fe/src/views/library/AudiobooksView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -514,9 +514,8 @@
}}
</div>
</div>
<div v-if="audiobook.series" class="detail-line small">
Series: {{ safeText(audiobook.series)
}}<span v-if="audiobook.seriesNumber"> #{{ audiobook.seriesNumber }}</span>
<div v-if="formatSeriesMemberships(audiobook)" class="detail-line small">
Series: {{ safeText(formatSeriesMemberships(audiobook)) }}
</div>
<div class="detail-line small">
{{ safeText(audiobook.publisher)
Expand Down Expand Up @@ -591,9 +590,8 @@
}}
</div>
<div v-if="showItemDetails" class="list-extra-details">
<div v-if="audiobook.series" class="detail-line small">
Series: {{ safeText(audiobook.series)
}}<span v-if="audiobook.seriesNumber"> #{{ audiobook.seriesNumber }}</span>
<div v-if="formatSeriesMemberships(audiobook)" class="detail-line small">
Series: {{ safeText(formatSeriesMemberships(audiobook)) }}
</div>
<div class="detail-line small">
{{
Expand Down Expand Up @@ -834,6 +832,7 @@ import { evaluateRules } from '@/utils/customFilterEvaluator'
import type { RuleLike } from '@/utils/customFilterEvaluator'
import { computeAudiobookStatus, formatAudiobookStatus } from '@/utils/audiobookStatus'
import { safeText } from '@/utils/textUtils'
import { formatSeriesMemberships } from '@/utils/seriesUtils'
import { getPlaceholderUrl } from '@/utils/placeholder'
import { errorTracking } from '@/services/errorTracking'
import { isLikelyBackendImageUrl, useProtectedImages } from '@/composables/useProtectedImages'
Expand Down Expand Up @@ -1358,6 +1357,27 @@ watch(groupBy, (v) => {

// (grouping sync handled earlier in file)

// All series a book belongs to (deduped), so a multi-series book is grouped under each
// of its series rather than only its primary. Falls back to the legacy single series.
function getBookSeriesNames(book: Audiobook): string[] {
const memberships = book.seriesMemberships
if (memberships && memberships.length > 0) {
const names: string[] = []
const seen = new Set<string>()
for (const membership of memberships) {
const name = (membership.seriesName || '').trim()
if (!name) continue
const dedupeKey = name.toLowerCase()
if (seen.has(dedupeKey)) continue
seen.add(dedupeKey)
names.push(name)
}
if (names.length > 0) return names
}
const legacy = (book.series || '').trim()
return legacy ? [legacy] : []
}

const groupedCollections = computed(() => {
if (groupBy.value === 'books') return []

Expand All @@ -1368,8 +1388,14 @@ const groupedCollections = computed(() => {
>()

books.forEach((book) => {
const key = groupBy.value === 'authors' ? book.authors?.[0] : book.series
if (key) {
const keys =
groupBy.value === 'authors'
? book.authors?.[0]
? [book.authors[0]]
: []
: getBookSeriesNames(book)
for (const key of keys) {
if (!key) continue
if (!groups.has(key)) {
if (groupBy.value === 'authors') {
// Prefer override (fetched author image) first, then author ASIN, then book cover
Expand Down
Loading
Loading