diff --git a/fe/src/__tests__/DownloadClientFormModal.spec.ts b/fe/src/__tests__/DownloadClientFormModal.spec.ts index 17beed61d..23320dabd 100644 --- a/fe/src/__tests__/DownloadClientFormModal.spec.ts +++ b/fe/src/__tests__/DownloadClientFormModal.spec.ts @@ -172,4 +172,47 @@ describe('DownloadClientFormModal', () => { expect(calledWith.password).toBe('') expect(calledWith.id).toBe('4') }) + + it('renders URL Base field for qbittorrent and includes it in the test payload when set', async () => { + const api = await import('@/services/api') + ;(api.testDownloadClient as unknown) = vi.fn(async (config: unknown) => ({ + success: true, + message: 'ok', + client: config, + })) + + const wrapper = mount(DownloadClientFormModal, { + global: { plugins: [createPinia()] }, + props: { visible: true, editingClient: null }, + }) + + await wrapper.setProps({ + editingClient: { + id: '5', + name: 'qbt', + type: 'qbittorrent', + host: 'qbittorrent.local', + port: 8080, + isEnabled: true, + useSSL: false, + downloadPath: '', + username: '', + password: '', + settings: {}, + }, + }) + await wrapper.vm.$nextTick() + + const urlBaseInput = wrapper.find('input[id="urlBase"]') + expect(urlBaseInput.exists()).toBe(true) + + await urlBaseInput.setValue('/qbittorrent') + + const testButton = wrapper.find('button.btn-info') + await testButton.trigger('click') + + expect(api.testDownloadClient as unknown).toHaveBeenCalled() + const calledWith = (api.testDownloadClient as unknown).mock.calls[0][0] + expect(calledWith.settings.urlBase).toBe('/qbittorrent') + }) }) diff --git a/fe/src/__tests__/LibraryImportFooter.spec.ts b/fe/src/__tests__/LibraryImportFooter.spec.ts index ca97b90bb..26cfb578a 100644 --- a/fe/src/__tests__/LibraryImportFooter.spec.ts +++ b/fe/src/__tests__/LibraryImportFooter.spec.ts @@ -25,9 +25,10 @@ import type { SearchResult, RootFolder } from '@/types' const success = vi.fn() const error = vi.fn() +const warning = vi.fn() vi.mock('@/services/toastService', () => ({ - useToast: () => ({ success, error }), + useToast: () => ({ success, error, warning }), })) describe('LibraryImportFooter', () => { @@ -161,4 +162,39 @@ describe('LibraryImportFooter', () => { await importButton.trigger('click') expect(importSelected).not.toHaveBeenCalled() }) + + it('discloses copy-and-retain policy before a move from weak storage', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useLibraryImportStore() + store.action = 'move' + + const wrapper = mount(LibraryImportFooter, { + props: { + folders: [ + { + id: 1, + path: 'D:\\library', + canPublishNewFiles: true, + canMutateFilesystem: true, + }, + ] as unknown as RootFolder[], + sourceFolder: { + id: 2, + path: '\\\\nas\\audiobooks', + canPublishNewFiles: true, + canMutateFilesystem: false, + } as unknown as RootFolder, + }, + global: { plugins: [pinia] }, + }) + + const policy = wrapper.get('[data-testid="move-policy-warning"]') + expect(policy.text()).toContain('Move will copy the selected files and retain the source') + expect(policy.text()).toContain('will not attempt source cleanup') + + store.action = 'hardlink/copy' + await wrapper.vm.$nextTick() + expect(wrapper.find('[data-testid="move-policy-warning"]').exists()).toBe(false) + }) }) diff --git a/fe/src/__tests__/RootFoldersSettings.spec.ts b/fe/src/__tests__/RootFoldersSettings.spec.ts index 75276c8b2..99d0b6f41 100644 --- a/fe/src/__tests__/RootFoldersSettings.spec.ts +++ b/fe/src/__tests__/RootFoldersSettings.spec.ts @@ -384,6 +384,27 @@ describe('RootFoldersSettings', () => { wrapper.unmount() }) + it('states that weak-storage moves copy and retain the source', async () => { + vi.mocked(apiService.getRootFolders).mockResolvedValue([ + { + ...rootFolder(null), + storageState: 'Limited', + storageReason: 'IdentityUnsupported', + storageMessage: 'Durable file identity is unavailable.', + canPublishNewFiles: true, + canMutateFilesystem: false, + }, + ]) + const pinia = createReadyPinia() + const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) + await flushPromises() + + const policy = wrapper.get('[data-cy="compatibility-publication-message"]') + expect(policy.text()).toContain('will copy files into this storage and retain the source') + expect(policy.text()).toContain('will not attempt source cleanup') + wrapper.unmount() + }) + it('shows initializing, blocks filesystem actions, and keeps metadata editing available', async () => { const folder = { ...rootFolder(null), diff --git a/fe/src/__tests__/libraryImport.store.spec.ts b/fe/src/__tests__/libraryImport.store.spec.ts index 856946ba0..09a41c38a 100644 --- a/fe/src/__tests__/libraryImport.store.spec.ts +++ b/fe/src/__tests__/libraryImport.store.spec.ts @@ -101,7 +101,20 @@ describe('library import store', () => { } store.action = 'move' - await store.importSelected('D:\\library') + startManualImport.mockResolvedValueOnce({ + importedCount: 3, + totalCount: 3, + results: [ + { + success: true, + sourcePath: 'C:\\incoming\\Part 1.mp3', + destinationPath: 'D:\\library\\Ordered Book\\Part 1.mp3', + warning: 'The source file was retained because durable identity is unavailable.', + }, + ], + }) + + const result = await store.importSelected('D:\\library') expect(addToLibrary).toHaveBeenCalledTimes(1) expect(startManualImport).toHaveBeenCalledTimes(1) @@ -117,6 +130,9 @@ describe('library import store', () => { { fullPath: 'C:\\incoming\\Part 10.mp3', matchedAudiobookId: 42 }, ], }) + expect(result.warnings).toEqual([ + 'The source file was retained because durable identity is unavailable.', + ]) }) it('registers files in place using the discovered book folder and backend success result', async () => { @@ -176,7 +192,7 @@ describe('library import store', () => { }, ], }) - expect(result).toEqual({ imported: 1, errors: [] }) + expect(result).toEqual({ imported: 1, errors: [], warnings: [] }) expect(store.itemList).toHaveLength(0) }) @@ -442,4 +458,103 @@ describe('library import store', () => { expect(store.hasUnprocessedItems).toBe(true) expect(store.failedCount).toBe(1) }) + + 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/components/domain/audiobook/LibraryImportFooter.vue b/fe/src/components/domain/audiobook/LibraryImportFooter.vue index 4888d1613..5bd6ce2b6 100644 --- a/fe/src/components/domain/audiobook/LibraryImportFooter.vue +++ b/fe/src/components/domain/audiobook/LibraryImportFooter.vue @@ -59,6 +59,18 @@ {{ store.failedCount }} lookup{{ store.failedCount === 1 ? '' : 's' }} failed - these were not matched and will be retried on the next run + +
/qbittorrent). Must match the prefix your reverse proxy strips before
+ forwarding to qBittorrent's own root-relative API - qBittorrent itself has no
+ built-in base path setting. Leave blank if qBittorrent is reachable directly at the
+ host/port above.
+ RPC path for the Transmission endpoint. Default is /transmission/rpc.
Some seedbox providers use a custom path (e.g. /rpc).
@@ -496,6 +503,14 @@ const getPortHelpText = () => {
return hints[formData.value.type] || 'Port the download client is listening on.'
}
+const showUrlBase = computed(() => {
+ return formData.value.type === 'transmission' || formData.value.type === 'qbittorrent'
+})
+
+const getUrlBasePlaceholder = () => {
+ return formData.value.type === 'qbittorrent' ? '/qbittorrent' : '/transmission/rpc'
+}
+
const getCategoryHelp = () => {
if (isUsenet.value) {
return 'Adding a category specific to Listenarr avoids conflicts with unrelated non-Listenarr downloads. Using a category is optional, but strongly recommended.'
@@ -593,9 +608,7 @@ const testConnection = async () => {
...(formData.value.type === 'sabnzbd' && formData.value.apiKey
? { apiKey: formData.value.apiKey }
: {}),
- ...(formData.value.type === 'transmission' && formData.value.urlBase
- ? { urlBase: formData.value.urlBase }
- : {}),
+ ...(showUrlBase.value && formData.value.urlBase ? { urlBase: formData.value.urlBase } : {}),
...(formData.value.category && { category: formData.value.category }),
...(formData.value.tags && { tags: formData.value.tags }),
recentPriority: formData.value.recentPriority,
@@ -653,9 +666,7 @@ const handleSubmit = async () => {
...(formData.value.type === 'sabnzbd' && formData.value.apiKey
? { apiKey: formData.value.apiKey }
: {}),
- ...(formData.value.type === 'transmission' && formData.value.urlBase
- ? { urlBase: formData.value.urlBase }
- : {}),
+ ...(showUrlBase.value && formData.value.urlBase ? { urlBase: formData.value.urlBase } : {}),
...(formData.value.category && { category: formData.value.category }),
...(formData.value.tags && { tags: formData.value.tags }),
recentPriority: formData.value.recentPriority,
diff --git a/fe/src/components/settings/RootFoldersSettings.vue b/fe/src/components/settings/RootFoldersSettings.vue
index aefe69914..4e90e77cf 100644
--- a/fe/src/components/settings/RootFoldersSettings.vue
+++ b/fe/src/components/settings/RootFoldersSettings.vue
@@ -178,6 +178,14 @@
>
{{ folder.storageMessage }}
+