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 }, )