From ba938908a7f717597700333967d96231d1ec739b Mon Sep 17 00:00:00 2001 From: s3ntin3l8 <58235613+s3ntin3l8@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:48:51 +0000 Subject: [PATCH 1/4] feat: show audiobooks under all their series; fix Primary-series toggle (#658) - Library grouping and series collections list a book under every series membership, not just the metadata provider's primary - Library list payload now carries seriesMemberships (batched query, no blanket Include on the shared GetAllAsync) - Fix EditAudiobookModal save payload that re-flagged the first series as primary, reverting a non-default Primary selection - Preserve the user's chosen primary across a manual metadata rescan - Cards/modals list all series; {Series} folder naming follows the chosen primary Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 + .../EditAudiobookModal.moveOptions.spec.ts | 48 ++++++++ fe/src/__tests__/seriesUtils.spec.ts | 35 ++++++ .../audiobook/AudiobookDetailsModal.vue | 10 +- .../domain/audiobook/AudiobookModal.vue | 10 +- .../domain/audiobook/EditAudiobookModal.vue | 4 +- fe/src/utils/seriesUtils.ts | 45 +++++++ fe/src/views/library/AudiobooksView.vue | 42 +++++-- fe/src/views/library/CollectionView.vue | 31 ++++- .../Controllers/LibraryController.cs | 4 +- .../Audiobooks/LibraryAudiobookListItem.cs | 1 + .../Audiobooks/LibraryListService.cs | 13 ++ .../Repositories/IAudiobookRepository.cs | 1 + .../Common/AudiobookSeriesMembershipHelper.cs | 45 +++++++ .../Repositories/AudiobookRepository.cs | 16 +++ ...yController_LibraryListSlimPayloadTests.cs | 41 +++++++ .../Api/Services/RenameServiceTests.cs | 43 +++++++ .../AudiobookSeriesMembershipHelperTests.cs | 114 ++++++++++++++++++ 18 files changed, 484 insertions(+), 24 deletions(-) create mode 100644 fe/src/__tests__/seriesUtils.spec.ts create mode 100644 fe/src/utils/seriesUtils.ts create mode 100644 tests/Features/Domain/Common/AudiobookSeriesMembershipHelperTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 982b7c6df..27aaa203a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts b/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts index 17590007a..30f882c7b 100644 --- a/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts +++ b/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts @@ -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 + } + + // 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(() => {})) diff --git a/fe/src/__tests__/seriesUtils.spec.ts b/fe/src/__tests__/seriesUtils.spec.ts new file mode 100644 index 000000000..100940348 --- /dev/null +++ b/fe/src/__tests__/seriesUtils.spec.ts @@ -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('') + }) +}) diff --git a/fe/src/components/domain/audiobook/AudiobookDetailsModal.vue b/fe/src/components/domain/audiobook/AudiobookDetailsModal.vue index 4292bdb1c..95d55a403 100644 --- a/fe/src/components/domain/audiobook/AudiobookDetailsModal.vue +++ b/fe/src/components/domain/audiobook/AudiobookDetailsModal.vue @@ -129,15 +129,12 @@ -
+

Series & Genre Information

-
+
Series: - {{ book.series - }} #{{ book.seriesNumber }} + {{ formatSeriesMemberships(book) }}
Genres: @@ -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 diff --git a/fe/src/components/domain/audiobook/AudiobookModal.vue b/fe/src/components/domain/audiobook/AudiobookModal.vue index 4292bdb1c..95d55a403 100644 --- a/fe/src/components/domain/audiobook/AudiobookModal.vue +++ b/fe/src/components/domain/audiobook/AudiobookModal.vue @@ -129,15 +129,12 @@
-
+

Series & Genre Information

-
+
Series: - {{ book.series - }} #{{ book.seriesNumber }} + {{ formatSeriesMemberships(book) }}
Genres: @@ -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 diff --git a/fe/src/components/domain/audiobook/EditAudiobookModal.vue b/fe/src/components/domain/audiobook/EditAudiobookModal.vue index 28894c71b..7632ab9dd 100644 --- a/fe/src/components/domain/audiobook/EditAudiobookModal.vue +++ b/fe/src/components/domain/audiobook/EditAudiobookModal.vue @@ -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, })) @@ -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), diff --git a/fe/src/utils/seriesUtils.ts b/fe/src/utils/seriesUtils.ts new file mode 100644 index 000000000..62612dc9a --- /dev/null +++ b/fe/src/utils/seriesUtils.ts @@ -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 . + */ +import type { Audiobook, AudiobookSeriesMembership } from '@/types' + +type SeriesBearer = Pick + +/** + * 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 +} diff --git a/fe/src/views/library/AudiobooksView.vue b/fe/src/views/library/AudiobooksView.vue index 14e3b7b7a..f7b7fa4ec 100644 --- a/fe/src/views/library/AudiobooksView.vue +++ b/fe/src/views/library/AudiobooksView.vue @@ -514,9 +514,8 @@ }}
-
- Series: {{ safeText(audiobook.series) - }} #{{ audiobook.seriesNumber }} +
+ Series: {{ safeText(formatSeriesMemberships(audiobook)) }}
{{ safeText(audiobook.publisher) @@ -591,9 +590,8 @@ }}
-
- Series: {{ safeText(audiobook.series) - }} #{{ audiobook.seriesNumber }} +
+ Series: {{ safeText(formatSeriesMemberships(audiobook)) }}
{{ @@ -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' @@ -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() + 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 [] @@ -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 diff --git a/fe/src/views/library/CollectionView.vue b/fe/src/views/library/CollectionView.vue index b25af88f4..cd2bd6d8f 100644 --- a/fe/src/views/library/CollectionView.vue +++ b/fe/src/views/library/CollectionView.vue @@ -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) { @@ -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 } + : {}), 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] diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs index 3d9cdc1e1..ed1d665b1 100644 --- a/listenarr.api/Controllers/LibraryController.cs +++ b/listenarr.api/Controllers/LibraryController.cs @@ -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, diff --git a/listenarr.application/Audiobooks/LibraryAudiobookListItem.cs b/listenarr.application/Audiobooks/LibraryAudiobookListItem.cs index d8e0902f4..483ea8c9a 100644 --- a/listenarr.application/Audiobooks/LibraryAudiobookListItem.cs +++ b/listenarr.application/Audiobooks/LibraryAudiobookListItem.cs @@ -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; } diff --git a/listenarr.application/Audiobooks/LibraryListService.cs b/listenarr.application/Audiobooks/LibraryListService.cs index d3ccbcee0..d1e8598f9 100644 --- a/listenarr.application/Audiobooks/LibraryListService.cs +++ b/listenarr.application/Audiobooks/LibraryListService.cs @@ -66,7 +66,9 @@ public async Task> GetAllAsync() var fileSummaryTask = _audiobookFileRepository.GetFormatSummariesAsync(); var fileCountTask = _audiobookFileRepository.GetCountsByAudiobookIdAsync(); var activeDownloadTask = _downloadRepository.GetActiveAudiobookIdsAsync(ActiveLibraryDownloadStatuses); + var seriesMembershipTask = _audiobookRepository.GetSeriesMembershipsByAudiobookIdsAsync(); + var membershipsByAudiobookId = await seriesMembershipTask; var fileSummaryRows = await fileSummaryTask; var fileCountById = await fileCountTask; var filesByAudiobookId = fileSummaryRows @@ -112,6 +114,17 @@ public async Task> GetAllAsync() PublishedDate = a.PublishedDate, Series = a.Series, SeriesNumber = a.SeriesNumber, + SeriesMemberships = membershipsByAudiobookId.TryGetValue(a.Id, out var memberships) && memberships.Count > 0 + ? memberships.Select(m => new AudiobookSeriesMembershipDto + { + Id = m.Id, + SeriesName = m.SeriesName, + SeriesNumber = m.SeriesNumber, + SeriesAsin = m.SeriesAsin, + IsPrimary = m.IsPrimary, + SortOrder = m.SortOrder, + }).ToArray() + : null, Genres = a.Genres?.ToArray(), Asin = a.Asin, OpenLibraryId = a.OpenLibraryId, diff --git a/listenarr.application/Interfaces/Repositories/IAudiobookRepository.cs b/listenarr.application/Interfaces/Repositories/IAudiobookRepository.cs index e6e09b2df..a4453684c 100644 --- a/listenarr.application/Interfaces/Repositories/IAudiobookRepository.cs +++ b/listenarr.application/Interfaces/Repositories/IAudiobookRepository.cs @@ -23,6 +23,7 @@ public interface IAudiobookRepository { Task> GetAllAsync(); Task> GetLibraryAsync(); + Task>> GetSeriesMembershipsByAudiobookIdsAsync(CancellationToken ct = default); Task> GetByIdsWithFilesAsync(IEnumerable ids, CancellationToken ct = default); Task> GetMonitoredAudiobooksForSearchAsync(DateTime cutoff, CancellationToken ct = default); Task NormalizeJsonColumnsAsync(CancellationToken ct = default); diff --git a/listenarr.domain/Common/AudiobookSeriesMembershipHelper.cs b/listenarr.domain/Common/AudiobookSeriesMembershipHelper.cs index 966e93f86..a970f816b 100644 --- a/listenarr.domain/Common/AudiobookSeriesMembershipHelper.cs +++ b/listenarr.domain/Common/AudiobookSeriesMembershipHelper.cs @@ -104,6 +104,51 @@ public static void ApplyToAudiobook( ApplyPrimarySeriesFields(audiobook); } + /// + /// Applies to the audiobook while preserving the user's + /// previously-chosen primary series. Used by metadata rescan so a refresh does not silently + /// revert the active series back to the metadata provider's default. If the incoming data no + /// longer contains the previously-primary series, the provider's default primary is kept. + /// + public static void ApplyToAudiobookPreservingPrimary( + Audiobook audiobook, + IEnumerable? memberships, + string? legacySeries = null, + string? legacySeriesNumber = null, + string? legacySeriesAsin = null) + { + // Capture the existing primary BEFORE replacing memberships. + var existingPrimary = GetPrimaryMembership(audiobook.SeriesMemberships); + var existingPrimaryName = NormalizeText(existingPrimary?.SeriesName); + var existingPrimaryAsin = NormalizeText(existingPrimary?.SeriesAsin); + + var normalized = Normalize(memberships, legacySeries, legacySeriesNumber, legacySeriesAsin); + + if (normalized.Count > 0 && (existingPrimaryName != null || existingPrimaryAsin != null)) + { + // Re-locate the previously-chosen primary in the refreshed list (ASIN first, then name). + var preserved = existingPrimaryAsin != null + ? normalized.FirstOrDefault(m => string.Equals( + NormalizeText(m.SeriesAsin), existingPrimaryAsin, StringComparison.OrdinalIgnoreCase)) + : null; + preserved ??= existingPrimaryName != null + ? normalized.FirstOrDefault(m => string.Equals( + NormalizeText(m.SeriesName), existingPrimaryName, StringComparison.OrdinalIgnoreCase)) + : null; + + if (preserved != null) + { + foreach (var membership in normalized) + { + membership.IsPrimary = ReferenceEquals(membership, preserved); + } + } + } + + audiobook.SeriesMemberships = normalized.Count == 0 ? null : normalized; + ApplyPrimarySeriesFields(audiobook); + } + public static void ApplyPrimarySeriesFields(Audiobook audiobook) { var primary = GetPrimaryMembership(audiobook.SeriesMemberships); diff --git a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs index 1914e3756..7f2172a9b 100644 --- a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs @@ -46,6 +46,22 @@ public async Task> GetLibraryAsync() .ToListAsync(); } + public async Task>> GetSeriesMembershipsByAudiobookIdsAsync(CancellationToken ct = default) + { + // Batch-load all memberships in one query (mirrors the file-summary batching in + // LibraryListService) so the library list can show a book under every series it + // belongs to without a per-row Include. + var memberships = await _db.AudiobookSeriesMemberships + .AsNoTracking() + .OrderByDescending(m => m.IsPrimary) + .ThenBy(m => m.SortOrder) + .ToListAsync(ct); + + return memberships + .GroupBy(m => m.AudiobookId) + .ToDictionary(g => g.Key, g => g.ToList()); + } + public async Task GetByAsinAsync(string asin) { var normalizedAsin = NormalizeAsin(asin); diff --git a/tests/Features/Api/Controllers/LibraryController_LibraryListSlimPayloadTests.cs b/tests/Features/Api/Controllers/LibraryController_LibraryListSlimPayloadTests.cs index 7818ca511..2f0b461f6 100644 --- a/tests/Features/Api/Controllers/LibraryController_LibraryListSlimPayloadTests.cs +++ b/tests/Features/Api/Controllers/LibraryController_LibraryListSlimPayloadTests.cs @@ -97,5 +97,46 @@ await _downloadRepository.AddAsync(new DownloadBuilder() Assert.False(item.TryGetProperty("description", out _)); Assert.False(item.TryGetProperty("subtitle", out _)); } + + [Fact] + [Trait("Method", "GetAll")] + [Trait("Scenario", "IncludesAllSeriesMemberships")] + public async Task GetAll_IncludesSeriesMemberships_ForMultiSeriesBook() + { + // Given a book that belongs to two series (e.g. publication + chronological order) + var book = new AudiobookBuilder() + .WithTitle("Multi Series Book") + .WithAuthor("Tom Clancy") + .WithSeries("Publication Order") + .WithSeriesNumber("1") + .Build(); + book.SeriesMemberships = new List + { + new() { SeriesName = "Publication Order", SeriesNumber = "1", IsPrimary = true, SortOrder = 0 }, + new() { SeriesName = "Chronological Order", SeriesNumber = "3", IsPrimary = false, SortOrder = 1 }, + }; + book = await _audiobookRepository.AddAsync(book); + + var controller = _provider.GetRequiredService(); + + // When + var actionResult = await controller.GetAll(); + + // Then both memberships are present in the slim list payload + var ok = Assert.IsType(actionResult); + var json = JsonSerializer.Serialize(ok.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + using var doc = JsonDocument.Parse(json); + var item = doc.RootElement + .EnumerateArray() + .Single(element => element.GetProperty("id").GetInt32() == book.Id); + + Assert.True(item.TryGetProperty("seriesMemberships", out var memberships)); + Assert.Equal(2, memberships.GetArrayLength()); + var names = memberships.EnumerateArray() + .Select(m => m.GetProperty("seriesName").GetString()) + .ToList(); + Assert.Contains("Publication Order", names); + Assert.Contains("Chronological Order", names); + } } } diff --git a/tests/Features/Api/Services/RenameServiceTests.cs b/tests/Features/Api/Services/RenameServiceTests.cs index 08986b155..48a2be5f2 100644 --- a/tests/Features/Api/Services/RenameServiceTests.cs +++ b/tests/Features/Api/Services/RenameServiceTests.cs @@ -18,6 +18,7 @@ using Listenarr.Application.Audiobooks; using Listenarr.Application.Common; using Listenarr.Application.Interfaces; +using Listenarr.Domain.Common; using Listenarr.Domain.Models; using Listenarr.Domain.Models.Configurations; using Listenarr.Domain.Models.Enumerations; @@ -455,6 +456,48 @@ public async Task ExecuteRename_MovesFileAndUpdatesDatabasePaths() Assert.Equal(NormalizePath(targetPath), NormalizePath(saved.Files!.Single().Path)); } + [Fact] + public async Task PreviewRename_SeriesToken_UsesChosenPrimarySeries() + { + // Regression for #658: {Series} must fold under the user-chosen primary series, + // even when it is not the metadata provider's default (first) series. + var settings = new ApplicationSettings + { + OutputPath = _tempRoot, + FolderNamingPattern = "{Author}/{Series}/{Title}", + FileNamingPattern = "{Title}" + }; + + var (service, db, _) = BuildService(settings); + var audiobook = new Audiobook + { + Id = 5, + Title = "Patriot Games", + Authors = new List { "Tom Clancy" }, + BasePath = Path.Join(_tempRoot, "Wrong", "Folder"), + SeriesMemberships = new List + { + new() { SeriesName = "Publication Order", SeriesNumber = "1", IsPrimary = false, SortOrder = 0 }, + new() { SeriesName = "Chronological Order", SeriesNumber = "3", IsPrimary = true, SortOrder = 1 }, + }, + Files = new List + { + new() { Id = 51, AudiobookId = 5, Path = Path.Join(_tempRoot, "Wrong", "Folder", "old.m4b"), Format = "m4b" } + } + }; + // Denormalize Series/SeriesNumber from the chosen primary membership, as the app does on save. + AudiobookSeriesMembershipHelper.ApplyPrimarySeriesFields(audiobook); + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + + var previews = await service.PreviewRenameAsync(new[] { 5 }); + + var preview = Assert.Single(previews); + Assert.True(preview.HasChanges); + Assert.Contains("Chronological Order", preview.NewFolderPath); + Assert.DoesNotContain("Publication Order", preview.NewFolderPath); + } + private (RenameService Service, ListenArrDbContext Db, string DbName) BuildService( ApplicationSettings settings, Action>? configureFileMover = null) diff --git a/tests/Features/Domain/Common/AudiobookSeriesMembershipHelperTests.cs b/tests/Features/Domain/Common/AudiobookSeriesMembershipHelperTests.cs new file mode 100644 index 000000000..11efd1b38 --- /dev/null +++ b/tests/Features/Domain/Common/AudiobookSeriesMembershipHelperTests.cs @@ -0,0 +1,114 @@ +/* + * 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 . + */ + +using Listenarr.Domain.Common; +using Listenarr.Domain.Models; +using Xunit; + +namespace Listenarr.Tests.Features.Domain.Common +{ + [Trait("Name", "AudiobookSeriesMembershipHelperTests")] + [Trait("Category", "Domain")] + public class AudiobookSeriesMembershipHelperTests + { + [Fact] + [Trait("Method", "Normalize")] + [Trait("Scenario", "KeepsChosenPrimaryAtNonZeroIndex")] + public void Normalize_KeepsChosenPrimary_WhenItIsNotTheFirstEntry() + { + // Given a membership list where the user's chosen primary is NOT the first entry + var memberships = new List + { + new() { SeriesName = "Publication Order", SeriesNumber = "1", IsPrimary = false }, + new() { SeriesName = "Chronological Order", SeriesNumber = "3", IsPrimary = true }, + }; + + // When + var result = AudiobookSeriesMembershipHelper.Normalize(memberships); + + // Then the chosen (second) series stays primary — never reverts to the first + var primary = AudiobookSeriesMembershipHelper.GetPrimaryMembership(result); + Assert.Equal("Chronological Order", primary?.SeriesName); + Assert.Single(result, m => m.IsPrimary); + } + + [Fact] + [Trait("Method", "ApplyToAudiobookPreservingPrimary")] + [Trait("Scenario", "PreservesUserPrimaryWhenProviderStillReturnsIt")] + public void ApplyToAudiobookPreservingPrimary_KeepsUserChoice_WhenProviderStillReturnsSeries() + { + // Given an audiobook whose user-chosen primary is the chronological-order series + var audiobook = new Audiobook + { + Title = "Patriot Games", + SeriesMemberships = new List + { + new() { SeriesName = "Publication Order", SeriesNumber = "1", SeriesAsin = "PUB", IsPrimary = false, SortOrder = 0 }, + new() { SeriesName = "Chronological Order", SeriesNumber = "3", SeriesAsin = "CHR", IsPrimary = true, SortOrder = 1 }, + }, + }; + AudiobookSeriesMembershipHelper.ApplyPrimarySeriesFields(audiobook); + + // When a metadata rescan returns the provider's data (publication-order marked primary) + var providerMemberships = new List + { + new() { SeriesName = "Publication Order", SeriesNumber = "1", SeriesAsin = "PUB", IsPrimary = true }, + new() { SeriesName = "Chronological Order", SeriesNumber = "3", SeriesAsin = "CHR", IsPrimary = false }, + }; + AudiobookSeriesMembershipHelper.ApplyToAudiobookPreservingPrimary( + audiobook, providerMemberships, "Publication Order", "1"); + + // Then the user's chosen primary is retained (incl. the denormalized fields used for naming) + var primary = AudiobookSeriesMembershipHelper.GetPrimaryMembership(audiobook.SeriesMemberships); + Assert.Equal("Chronological Order", primary?.SeriesName); + Assert.Equal("Chronological Order", audiobook.Series); + Assert.Equal("3", audiobook.SeriesNumber); + Assert.Single(audiobook.SeriesMemberships!, m => m.IsPrimary); + } + + [Fact] + [Trait("Method", "ApplyToAudiobookPreservingPrimary")] + [Trait("Scenario", "FallsBackToProviderPrimaryWhenChosenSeriesRemoved")] + public void ApplyToAudiobookPreservingPrimary_UsesProviderPrimary_WhenChosenSeriesNoLongerReturned() + { + // Given a user-chosen primary that the provider no longer returns + var audiobook = new Audiobook + { + Title = "Patriot Games", + SeriesMemberships = new List + { + new() { SeriesName = "Chronological Order", SeriesNumber = "3", SeriesAsin = "CHR", IsPrimary = true, SortOrder = 0 }, + }, + }; + AudiobookSeriesMembershipHelper.ApplyPrimarySeriesFields(audiobook); + + // When the rescan only returns a different series + var providerMemberships = new List + { + new() { SeriesName = "Publication Order", SeriesNumber = "1", SeriesAsin = "PUB", IsPrimary = true }, + }; + AudiobookSeriesMembershipHelper.ApplyToAudiobookPreservingPrimary( + audiobook, providerMemberships, "Publication Order", "1"); + + // Then it falls back to the provider's primary + var primary = AudiobookSeriesMembershipHelper.GetPrimaryMembership(audiobook.SeriesMemberships); + Assert.Equal("Publication Order", primary?.SeriesName); + Assert.Single(audiobook.SeriesMemberships!, m => m.IsPrimary); + } + } +} From 893e418bceee89b0e5a83e2aa40941419264adf2 Mon Sep 17 00:00:00 2001 From: s3ntin3l8 <58235613+s3ntin3l8@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:14:39 +0000 Subject: [PATCH 2/4] fix: address PR #659 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LibraryListService: await the three shared-scoped-context reads (file summaries, file counts, series memberships) sequentially. They all run on the scoped ListenArrDbContext, so firing them concurrently risked EF's "a second operation was started on this context instance" under real DB latency (only survived on SQLite because its async path runs sync). The download read uses IDbContextFactory (own context) and stays parallel. - CollectionView: render the resolved per-collection series position (#N) in the series collection list and grid item details — previously resolveSeriesForCollection computed it but nothing displayed it. - Drop the CHANGELOG entries (maintainer feedback: changelog no longer needed). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 -- fe/src/__tests__/CollectionView.spec.ts | 49 +++++++++++++++++++ fe/src/views/library/CollectionView.vue | 41 +++++++++++++++- .../Audiobooks/LibraryListService.cs | 15 +++--- 4 files changed, 98 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27aaa203a..982b7c6df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,7 @@ 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. diff --git a/fe/src/__tests__/CollectionView.spec.ts b/fe/src/__tests__/CollectionView.spec.ts index 6511d032a..958914d31 100644 --- a/fe/src/__tests__/CollectionView.spec.ts +++ b/fe/src/__tests__/CollectionView.spec.ts @@ -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: '
' } }, + { 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') + }) }) diff --git a/fe/src/views/library/CollectionView.vue b/fe/src/views/library/CollectionView.vue index cd2bd6d8f..73ec219f6 100644 --- a/fe/src/views/library/CollectionView.vue +++ b/fe/src/views/library/CollectionView.vue @@ -413,7 +413,13 @@ />
-
{{ safeText(audiobook.title) }}
+
+ #{{ audiobook.seriesNumber }}{{ safeText(audiobook.title) }} +
{{ audiobook.authors @@ -549,6 +555,12 @@ />
+
+ #{{ audiobook.seriesNumber }} +
> GetAllAsync() return Array.Empty(); } - var fileSummaryTask = _audiobookFileRepository.GetFormatSummariesAsync(); - var fileCountTask = _audiobookFileRepository.GetCountsByAudiobookIdAsync(); + // The download repository resolves its own DbContext from IDbContextFactory, so this + // read can safely run concurrently with the shared-context reads below. var activeDownloadTask = _downloadRepository.GetActiveAudiobookIdsAsync(ActiveLibraryDownloadStatuses); - var seriesMembershipTask = _audiobookRepository.GetSeriesMembershipsByAudiobookIdsAsync(); - var membershipsByAudiobookId = await seriesMembershipTask; - var fileSummaryRows = await fileSummaryTask; - var fileCountById = await fileCountTask; + // File summaries, file counts, and series memberships all execute against the shared + // scoped ListenArrDbContext, so they must be awaited sequentially: starting a second + // query on a single DbContext before the first completes throws "a second operation + // was started on this context instance" under real database latency. + var fileSummaryRows = await _audiobookFileRepository.GetFormatSummariesAsync(); + var fileCountById = await _audiobookFileRepository.GetCountsByAudiobookIdAsync(); + var membershipsByAudiobookId = await _audiobookRepository.GetSeriesMembershipsByAudiobookIdsAsync(); var filesByAudiobookId = fileSummaryRows .GroupBy(f => f.AudiobookId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); From d441e09314aed85c4c8025f291589745d1fc7936 Mon Sep 17 00:00:00 2001 From: s3ntin3l8 <58235613+s3ntin3l8@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:02:40 +0000 Subject: [PATCH 3/4] fix: series catalog uses the viewed series' position for not-added books MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A book in multiple series lists every series it belongs to, but the series catalog mapped each book via book.Series.FirstOrDefault() — its primary series. So opening "Series A" showed not-added (catalog) suggestions with their primary series number (e.g. "Series B #5") instead of their position in the series being viewed ("Series A #3"). Library items were already correct; only the remote catalog "Not Added" items were wrong. SeriesCatalogService now reorders each fetched book's series list so the catalogued series (matched by ASIN, else name) comes first, before the books are cached and returned. Both the cache write and the controller response mapping (both FirstOrDefault-based) then reflect the requested series. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Audiobooks/SeriesCatalogService.cs | 64 +++++++++++++++++ .../Api/Services/SeriesCatalogServiceTests.cs | 72 +++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/listenarr.application/Audiobooks/SeriesCatalogService.cs b/listenarr.application/Audiobooks/SeriesCatalogService.cs index f777e3f0b..fcb20b1a3 100644 --- a/listenarr.application/Audiobooks/SeriesCatalogService.cs +++ b/listenarr.application/Audiobooks/SeriesCatalogService.cs @@ -124,6 +124,15 @@ public SeriesCatalogService( .Take(Math.Clamp(limit, 1, 500)) .ToList(); + // Each fetched book lists every series it belongs to; for THIS series catalog we + // want each book's position within the series being viewed, not its primary series. + // Promote the matching series entry to the front so the (FirstOrDefault-based) + // response and cache mappings reflect the requested series. + foreach (var book in limitedBooks) + { + book.Series = PrioritizeCatalogSeries(book.Series, series.Asin, series.Name); + } + if (limitedBooks.Count == 0 && cachedEntry?.CatalogBooks != null && cachedEntry.CatalogBooks.Count > 0) @@ -161,6 +170,61 @@ await PersistCatalogAsync( }; } + // Returns the book's series list reordered so the entry for the catalogued series comes + // first (matched by ASIN, else by name). The full list is preserved; only the ordering + // changes, so the existing FirstOrDefault-based mappings pick the right series number. + private static List? PrioritizeCatalogSeries( + List? seriesList, + string? catalogAsin, + string? catalogName) + { + if (seriesList == null || seriesList.Count < 2) + { + return seriesList; + } + + var match = FindCatalogSeries(seriesList, catalogAsin, catalogName); + if (match == null || ReferenceEquals(match, seriesList[0])) + { + return seriesList; + } + + var reordered = new List(seriesList.Count) { match }; + reordered.AddRange(seriesList.Where(entry => !ReferenceEquals(entry, match))); + return reordered; + } + + private static AudibleSeries? FindCatalogSeries( + List seriesList, + string? catalogAsin, + string? catalogName) + { + if (!string.IsNullOrWhiteSpace(catalogAsin)) + { + var byAsin = seriesList.FirstOrDefault(entry => + !string.IsNullOrWhiteSpace(entry?.Asin) && + string.Equals(entry!.Asin, catalogAsin, StringComparison.OrdinalIgnoreCase)); + if (byAsin != null) + { + return byAsin; + } + } + + if (!string.IsNullOrWhiteSpace(catalogName)) + { + var target = catalogName.Trim(); + var byName = seriesList.FirstOrDefault(entry => + !string.IsNullOrWhiteSpace(entry?.Name) && + string.Equals(entry!.Name!.Trim(), target, StringComparison.OrdinalIgnoreCase)); + if (byName != null) + { + return byName; + } + } + + return null; + } + private async Task ResolveSeriesAsync( string normalizedName, string region, diff --git a/tests/Features/Api/Services/SeriesCatalogServiceTests.cs b/tests/Features/Api/Services/SeriesCatalogServiceTests.cs index cc2b508e9..da778a242 100644 --- a/tests/Features/Api/Services/SeriesCatalogServiceTests.cs +++ b/tests/Features/Api/Services/SeriesCatalogServiceTests.cs @@ -150,5 +150,77 @@ public async Task GetCatalogAsync_ForceRefresh_BypassesPersistedCatalogCache_And entry.CatalogBooks[0].Title == "The Final Empire")), Times.Once); } + + [Fact] + public async Task GetCatalogAsync_UsesPositionForRequestedSeries_NotThePrimary() + { + using var httpClientForAudible = new HttpClient(); + var audible = new Mock(httpClientForAudible, Mock.Of>()) { CallBase = false }; + var audiobookRepository = new Mock(); + var logger = new Mock>(); + + // Resolve the requested series ("Mistborn" = SERIES123) via the persisted cache entry. + audiobookRepository + .Setup(repository => repository.GetCachedSeriesByNameAsync("Mistborn", "us")) + .ReturnsAsync(new SeriesCacheEntry + { + SeriesName = "Mistborn", + SeriesNameNormalized = "mistborn", + SeriesAsin = "SERIES123", + Region = "us", + CatalogBooks = new List + { + new() { Title = "stale", Authors = new List { "Brandon Sanderson" } } + } + }); + + audiobookRepository + .Setup(repository => repository.UpsertCachedSeriesAsync(It.IsAny())) + .ReturnsAsync((SeriesCacheEntry entry) => entry); + + // The fetched book belongs to two series; its PRIMARY (first) entry is a different + // series, and the series being viewed ("Mistborn") is second, at position 3. + audible + .Setup(service => service.GetTypedBooksBySeriesAsinAsync("SERIES123", "us")) + .ReturnsAsync(new List + { + new() + { + Asin = "BOOK1", + Title = "The Alloy of Law", + Authors = new List { new() { Name = "Brandon Sanderson" } }, + Language = "english", + Series = new List + { + new() { Asin = "OTHER999", Name = "Wax and Wayne", Position = "5" }, + new() { Asin = "SERIES123", Name = "Mistborn", Position = "3" } + } + } + }); + + var service = new SeriesCatalogService( + audible.Object, + audiobookRepository.Object, + logger.Object); + + var result = await service.GetCatalogAsync("Mistborn", "us", 10, forceRefresh: true); + + Assert.NotNull(result); + var book = Assert.Single(result!.Books); + + // The catalog reflects the book's position in the requested series (Mistborn #3), + // not its primary series (Wax and Wayne #5). + Assert.Equal("Mistborn", book.Series!.First().Name); + Assert.Equal("3", book.Series!.First().Position); + + // The persisted cache stores the requested-series position too, so cache hits stay correct. + audiobookRepository.Verify( + repository => repository.UpsertCachedSeriesAsync(It.Is(entry => + entry.CatalogBooks != null && + entry.CatalogBooks.Count == 1 && + entry.CatalogBooks[0].Series == "Mistborn" && + entry.CatalogBooks[0].SeriesNumber == "3")), + Times.Once); + } } } From 16f828c6f5bfd6f416b29b0174b6615de6390dbd Mon Sep 17 00:00:00 2001 From: s3ntin3l8 <58235613+s3ntin3l8@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:55:19 +0000 Subject: [PATCH 4/4] refactor: rename misleading series-membership repo method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetSeriesMembershipsByAudiobookIdsAsync implied filtering by audiobook IDs, but it takes no IDs and returns the entire AudiobookSeriesMemberships table grouped by AudiobookId. The sole caller (LibraryListService, the full library list) wants all rows, so the behavior is correct — only the name was misleading (a footgun for future callers who might pass IDs and silently get every row). Renamed to GetAllSeriesMembershipsGroupedByAudiobookIdAsync across the interface, implementation, and caller. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- listenarr.application/Audiobooks/LibraryListService.cs | 2 +- .../Interfaces/Repositories/IAudiobookRepository.cs | 2 +- .../Persistence/Repositories/AudiobookRepository.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/listenarr.application/Audiobooks/LibraryListService.cs b/listenarr.application/Audiobooks/LibraryListService.cs index 34bad2402..0d2caa60e 100644 --- a/listenarr.application/Audiobooks/LibraryListService.cs +++ b/listenarr.application/Audiobooks/LibraryListService.cs @@ -73,7 +73,7 @@ public async Task> GetAllAsync() // was started on this context instance" under real database latency. var fileSummaryRows = await _audiobookFileRepository.GetFormatSummariesAsync(); var fileCountById = await _audiobookFileRepository.GetCountsByAudiobookIdAsync(); - var membershipsByAudiobookId = await _audiobookRepository.GetSeriesMembershipsByAudiobookIdsAsync(); + var membershipsByAudiobookId = await _audiobookRepository.GetAllSeriesMembershipsGroupedByAudiobookIdAsync(); var filesByAudiobookId = fileSummaryRows .GroupBy(f => f.AudiobookId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); diff --git a/listenarr.application/Interfaces/Repositories/IAudiobookRepository.cs b/listenarr.application/Interfaces/Repositories/IAudiobookRepository.cs index a4453684c..b4b2c7719 100644 --- a/listenarr.application/Interfaces/Repositories/IAudiobookRepository.cs +++ b/listenarr.application/Interfaces/Repositories/IAudiobookRepository.cs @@ -23,7 +23,7 @@ public interface IAudiobookRepository { Task> GetAllAsync(); Task> GetLibraryAsync(); - Task>> GetSeriesMembershipsByAudiobookIdsAsync(CancellationToken ct = default); + Task>> GetAllSeriesMembershipsGroupedByAudiobookIdAsync(CancellationToken ct = default); Task> GetByIdsWithFilesAsync(IEnumerable ids, CancellationToken ct = default); Task> GetMonitoredAudiobooksForSearchAsync(DateTime cutoff, CancellationToken ct = default); Task NormalizeJsonColumnsAsync(CancellationToken ct = default); diff --git a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs index 7f2172a9b..0068a20a5 100644 --- a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs @@ -46,7 +46,7 @@ public async Task> GetLibraryAsync() .ToListAsync(); } - public async Task>> GetSeriesMembershipsByAudiobookIdsAsync(CancellationToken ct = default) + public async Task>> GetAllSeriesMembershipsGroupedByAudiobookIdAsync(CancellationToken ct = default) { // Batch-load all memberships in one query (mirrors the file-summary batching in // LibraryListService) so the library list can show a book under every series it