Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Books in multiple series now appear under every series:** the library "Series" grouping and the series collection view list a book under all of its series memberships (not just the metadata provider's primary), each showing the book's number for that series, and book cards/detail modals list all series the book belongs to ([#658](https://github.com/Listenarrs/Listenarr/issues/658)).

### Fixed
- **Series "Primary" toggle now persists:** choosing a non-default series as Primary in the edit dialog was silently reverted because the save payload always re-flagged the first series as primary, so the backend (which keeps the first primary it finds) kept the provider's original choice; the payload now sends only the user's selection ([#658](https://github.com/Listenarrs/Listenarr/issues/658)).
- **Metadata rescan keeps your chosen primary series:** a manual "Rescan Metadata" previously overwrote the active series with the provider's default; the rescan now re-applies your chosen primary when the provider still returns that series ([#658](https://github.com/Listenarrs/Listenarr/issues/658)).
- **Authentication settings: startup-config save no longer offers a downloadable `config.json` fallback when the backend refuses the save as invalid.** `SettingsView.saveSettings()` previously wrapped `apiService.saveStartupConfig` in a bare `catch {}` and treated every failure as a disk-persistence problem — offering the user a downloadable `config.json` containing the *server-rejected* values so they could save it manually. That bypasses the new backend admin-existence guard entirely: a user who tries to enable the login screen with no admin user gets the backend's 400, the FE catches it, and the FE offers a download of the same `AuthenticationRequired=true` config the server just refused. The catch now inspects the thrown error's `status`: 4xx responses are validation refusals and surface as a hard error toast (no download offered); 5xx and network failures fall through to the existing download fallback, which is the right escape hatch for "server wants to save but can't write to disk."
- **Authentication settings: enabling the login screen now refuses to persist when no admin user exists.** `ConfigurationService.SaveStartupConfigAsync` queries `IUserService.GetAdminUsersAsync` whenever the incoming save *transitions* `AuthenticationRequired` from disabled to enabled, and throws if the admin user list is empty. This closes the carveout left by the credential-visibility and admin-provisioning fixes below: the settings DTO clears blank fields before save, so a user who flips "Enable login screen" with empty (or username-only) admin credentials silently skipped provisioning entirely and still reached the startup-config write, locking themselves out of an admin-less instance (recoverable by editing `config/config.json` back to `"AuthenticationRequired": "false"`, but a confusing first-time-setup trap). The check is scoped to the transition: subsequent saves while auth is already on (API key regenerations, port changes, log-level tweaks) don't re-query the admin list, and the common "just updating other startup fields with auth off" path stays unaffected. The admin block in `SaveApplicationSettings` runs before the startup-config write in the same save flow, so the typical "supply credentials and enable login in the same save" sequence has the admin row in place by the time the check runs.
- **Authentication settings: admin provisioning failures no longer silently let the auth-required toggle proceed.** `ConfigurationService.SaveApplicationSettingsAsync` previously caught any exception from `CreateUserAsync` / `UpdatePasswordAsync`, logged it, and returned successfully — so when admin credentials were supplied but the user-service rejected them (password policy violation, repo I/O error, concurrent-write race), `SettingsView.saveSettings()` would still go on to persist `AuthenticationRequired=true` on its second request. The result was an instance that required login but had no working admin account — exactly the lockout shape the credential-visibility fix below was meant to prevent. The catch now re-throws the failure so the caller aborts before the auth-toggle write. The settings row itself is still saved before the admin block (non-admin changes like notification triggers and webhooks shouldn't disappear because admin provisioning failed), and the no-credentials path remains an unchanged silent skip.
Expand Down
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
31 changes: 30 additions & 1 deletion fe/src/views/library/CollectionView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -962,7 +962,14 @@ function matchesCurrentCollection(book: Audiobook): boolean {
}

if (type.value === 'series') {
return normalizeCollectionText(book.series) === normalizeCollectionText(name.value)
const target = normalizeCollectionText(name.value)
const memberships = book.seriesMemberships
if (memberships && memberships.length > 0) {
return memberships.some(
(membership) => normalizeCollectionText(membership.seriesName) === target,
)
}
return normalizeCollectionText(book.series) === target
}

if (isGenreCollection.value) {
Expand All @@ -985,14 +992,36 @@ function matchesCurrentCollection(book: Audiobook): boolean {
}

function mapLibraryItem(book: Audiobook): CollectionDisplayItem {
// In a series collection a book may be matched via a non-primary membership, so show the
// series name/number for THIS collection rather than the book's primary series.
const seriesContext = type.value === 'series' ? resolveSeriesForCollection(book) : null
return {
...book,
...(seriesContext
? { series: seriesContext.seriesName, seriesNumber: seriesContext.seriesNumber }
Comment thread
s3ntin3l8 marked this conversation as resolved.
: {}),
key: `library-${book.id}`,
inLibrary: true,
addMetadata: null,
}
}

function resolveSeriesForCollection(
book: Audiobook,
): { seriesName: string; seriesNumber?: string } | null {
const target = normalizeCollectionText(name.value)
const memberships = book.seriesMemberships
if (memberships && memberships.length > 0) {
const match = memberships.find(
(membership) => normalizeCollectionText(membership.seriesName) === target,
)
if (match) {
return { seriesName: match.seriesName, seriesNumber: match.seriesNumber }
}
}
return null
}

function buildCatalogMetadata(book: RemoteCatalogBook): AudibleBookMetadata {
const authors = (book.authors || []).filter(Boolean)
const publishYear = book.publishedDate?.match(/\d{4}/)?.[0]
Expand Down
4 changes: 3 additions & 1 deletion listenarr.api/Controllers/LibraryController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3998,7 +3998,9 @@ private static bool ApplyMetadataRescanPatch(Audiobook audiobook, AudibleBookMet
!string.IsNullOrWhiteSpace(metadata.Series) ||
!string.IsNullOrWhiteSpace(metadata.SeriesNumber))
{
AudiobookSeriesMembershipHelper.ApplyToAudiobook(
// Preserve the user's manually-chosen primary series across a rescan rather than
// reverting to the metadata provider's default (see issue #658).
AudiobookSeriesMembershipHelper.ApplyToAudiobookPreservingPrimary(
audiobook,
metadata.SeriesMemberships,
metadata.Series,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public class LibraryAudiobookListItem
public string? PublishedDate { get; set; }
public string? Series { get; set; }
public string? SeriesNumber { get; set; }
public AudiobookSeriesMembershipDto[]? SeriesMemberships { get; set; }
public string[]? Genres { get; set; }
public string? Asin { get; set; }
public string? OpenLibraryId { get; set; }
Expand Down
Loading