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/__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..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 }} +
0) { + return memberships.some( + (membership) => normalizeCollectionText(membership.seriesName) === target, + ) + } + return normalizeCollectionText(book.series) === target } if (isGenreCollection.value) { @@ -985,14 +1004,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] @@ -4219,6 +4260,33 @@ defineExpose({ overflow: hidden; } +/* Series position indicator (only shown inside a single-series collection) */ +.list-series-position { + display: inline-block; + margin-right: 0.4rem; + padding: 0 0.35rem; + border-radius: 4px; + font-size: 0.8em; + font-weight: 700; + color: var(--brand-500); + background-color: rgba(var(--brand-rgb), 0.16); +} + +.series-position-badge { + position: absolute; + top: 8px; + left: 8px; + z-index: 2; + padding: 0.15rem 0.45rem; + border-radius: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + color: #fff; + background-color: rgba(var(--brand-rgb), 0.92); + pointer-events: none; +} + .list-details .audiobook-title { white-space: nowrap; overflow: hidden; 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..0d2caa60e 100644 --- a/listenarr.application/Audiobooks/LibraryListService.cs +++ b/listenarr.application/Audiobooks/LibraryListService.cs @@ -63,12 +63,17 @@ public async Task> 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 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.GetAllSeriesMembershipsGroupedByAudiobookIdAsync(); var filesByAudiobookId = fileSummaryRows .GroupBy(f => f.AudiobookId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); @@ -112,6 +117,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/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/listenarr.application/Interfaces/Repositories/IAudiobookRepository.cs b/listenarr.application/Interfaces/Repositories/IAudiobookRepository.cs index e6e09b2df..b4b2c7719 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>> 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.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..0068a20a5 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>> 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 + // 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/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); + } } } 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); + } + } +}