From 93f909c11143212a2e6ce101d2a3ed48cbe8b9ea Mon Sep 17 00:00:00 2001 From: m4bard Date: Wed, 19 Aug 2026 14:56:52 -0500 Subject: [PATCH] Library import: keep every series membership, not just the first matchToMetadata collapsed a search result's series array to its first entry and sent only the legacy scalar series/seriesNumber. AudibleBookMetadata already carries SeriesMemberships, and /library/add applies it via AudiobookSeriesMembershipHelper.ApplyToAudiobook, falling back to the single scalar only when no memberships are supplied. So a book in more than one series lost every membership after the first on this path, while the Add New path (AddLibraryModal) preserved them. Build one membership per series entry, ordered, with the first marked primary. The scalar fields stay populated from the primary so nothing downstream changes. The search endpoint's fallback branch fills a series entry's asin with the series name when it cannot re-fetch the book by ASIN. That value used to be discarded because AudibleBookMetadata has no top-level SeriesAsin; a membership would persist it, so only a value shaped like an ASIN is kept. --- fe/src/__tests__/libraryImport.store.spec.ts | 99 ++++++++++++++++++++ fe/src/stores/libraryImport.ts | 38 ++++++-- 2 files changed, 131 insertions(+), 6 deletions(-) diff --git a/fe/src/__tests__/libraryImport.store.spec.ts b/fe/src/__tests__/libraryImport.store.spec.ts index c224d9592..43b0c6701 100644 --- a/fe/src/__tests__/libraryImport.store.spec.ts +++ b/fe/src/__tests__/libraryImport.store.spec.ts @@ -401,4 +401,103 @@ describe('library import store', () => { 'Jack of Shadows', ) }) + + it('carries every series membership of a multi-series match into the add request', async () => { + const { useLibraryImportStore } = await import('@/stores/libraryImport') + const store = useLibraryImportStore() + + // A book can legitimately belong to more than one series. Audnexus returns these as + // seriesPrimary/seriesSecondary and the backend builds one membership per entry, so the + // import path has to send both rather than keeping only the first. + store.items = { + '/incoming/Two Series/Book.m4b': { + id: '/incoming/Two Series/Book.m4b', + fullPath: '/incoming/Two Series/Book.m4b', + sourceFiles: ['/incoming/Two Series/Book.m4b'], + folderPath: '/incoming/Two Series', + relativePath: 'Two Series', + folderName: 'Two Series', + format: 'M4B', + fileCount: 1, + selectedMatch: { + title: 'Two Series Book', + authors: [{ name: 'Author' }], + series: [ + { asin: 'B01E633FQM', name: 'First Series', position: '0' }, + { asin: 'B01F5TL5K4', name: 'Second Series', position: '7' }, + ], + } as unknown as SearchResult, + hasSearched: true, + isSearching: false, + selected: true, + }, + } + store.action = 'none' + + await store.importSelected('') + + const metadata = addToLibrary.mock.calls[0][0] + expect(metadata.seriesMemberships).toEqual([ + { + seriesName: 'First Series', + seriesNumber: '0', + seriesAsin: 'B01E633FQM', + isPrimary: true, + sortOrder: 0, + }, + { + seriesName: 'Second Series', + seriesNumber: '7', + seriesAsin: 'B01F5TL5K4', + isPrimary: false, + sortOrder: 1, + }, + ]) + // The primary is still mirrored onto the legacy scalars. + expect(metadata.series).toBe('First Series') + expect(metadata.seriesNumber).toBe('0') + }) + + it('drops a series asin that is really the series name', async () => { + const { useLibraryImportStore } = await import('@/stores/libraryImport') + const store = useLibraryImportStore() + + // When the search endpoint cannot re-fetch the book by ASIN it synthesizes a single + // series entry whose `asin` is a copy of the series name. That must not be persisted + // as a series ASIN. + store.items = { + '/incoming/Fallback/Book.m4b': { + id: '/incoming/Fallback/Book.m4b', + fullPath: '/incoming/Fallback/Book.m4b', + sourceFiles: ['/incoming/Fallback/Book.m4b'], + folderPath: '/incoming/Fallback', + relativePath: 'Fallback', + folderName: 'Fallback', + format: 'M4B', + fileCount: 1, + selectedMatch: { + title: 'Fallback Book', + authors: [{ name: 'Author' }], + series: [{ asin: 'Some Series', name: 'Some Series', position: '2' }], + } as unknown as SearchResult, + hasSearched: true, + isSearching: false, + selected: true, + }, + } + store.action = 'none' + + await store.importSelected('') + + const metadata = addToLibrary.mock.calls[0][0] + expect(metadata.seriesMemberships).toEqual([ + { + seriesName: 'Some Series', + seriesNumber: '2', + seriesAsin: undefined, + isPrimary: true, + sortOrder: 0, + }, + ]) + }) }) diff --git a/fe/src/stores/libraryImport.ts b/fe/src/stores/libraryImport.ts index f008fd1f6..8b8fb449e 100644 --- a/fe/src/stores/libraryImport.ts +++ b/fe/src/stores/libraryImport.ts @@ -21,7 +21,12 @@ import { apiService } from '@/services/api' import { signalRService } from '@/services/signalr' import { logger } from '@/utils/logger' import { buildLibraryImportSearchParams } from '@/utils/libraryImportSearch' -import type { SearchResult, AudibleBookMetadata, UnmatchedFileItem } from '@/types' +import type { + SearchResult, + AudibleBookMetadata, + AudiobookSeriesMembership, + UnmatchedFileItem, +} from '@/types' export interface LibraryImportItem { id: string // = fullPath (unique key) @@ -45,6 +50,8 @@ export interface LibraryImportItem { selected: boolean } +const ASIN_PATTERN = /^[A-Z0-9]{10}$/i + function extractFolderName(relativePath: string): string { const parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean) // Prefer the last meaningful segment (author/title structure) @@ -100,11 +107,29 @@ function matchToMetadata(result: SearchResult): AudibleBookMetadata { ? result.authors.map((a) => a.name ?? '').filter(Boolean) : [] - // series may come back as AudibleSeries[] from the search endpoint + // series may come back as AudibleSeries[] from the search endpoint. A book can belong to + // more than one series (Audnexus seriesPrimary/seriesSecondary), so every entry becomes a + // membership; the scalar fields below stay populated from the primary for older consumers. const seriesRaw = result.series as unknown - const seriesItem = Array.isArray(seriesRaw) - ? (seriesRaw as Array<{ name?: string; asin?: string; position?: string }>)[0] - : null + const seriesEntries = Array.isArray(seriesRaw) + ? (seriesRaw as Array<{ name?: string; asin?: string; position?: string }>) + : [] + const seriesMemberships: AudiobookSeriesMembership[] = seriesEntries + .filter((entry) => (entry?.name ?? '').trim().length > 0) + .map((entry, index) => { + const asin = entry.asin?.trim() + return { + seriesName: (entry.name ?? '').trim(), + seriesNumber: entry.position?.trim() || undefined, + // The search fallback branch fills `asin` with the series *name* when the ASIN + // re-fetch fails, so only keep a value that actually looks like an ASIN. Until now + // that bogus value was discarded anyway; a membership would persist it. + seriesAsin: asin && ASIN_PATTERN.test(asin) ? asin : undefined, + isPrimary: index === 0, + sortOrder: index, + } + }) + const seriesItem = seriesEntries[0] ?? null const series = seriesItem?.name ?? (typeof seriesRaw === 'string' ? seriesRaw : undefined) const seriesNumber = seriesItem?.position ?? result.seriesNumber const seriesAsin = seriesItem?.asin ?? result.seriesAsin @@ -117,6 +142,7 @@ function matchToMetadata(result: SearchResult): AudibleBookMetadata { series, seriesNumber, seriesAsin, + ...(seriesMemberships.length > 0 ? { seriesMemberships } : {}), description: result.description, publisher: result.publisher, language: result.language, @@ -406,7 +432,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => { items.value[id] = { ...item, isSearching: true } try { - const isAsin = /^[A-Z0-9]{10}$/i.test(query.trim()) + const isAsin = ASIN_PATTERN.test(query.trim()) const results = await apiService.advancedSearch( isAsin ? { asin: query.trim(), cap: 5 } : { title: query, cap: 5 }, )