Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions fe/src/__tests__/libraryImport.store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
])
})
})
38 changes: 32 additions & 6 deletions fe/src/stores/libraryImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 },
)
Expand Down