Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
c12d4c9
fix(downloads): orphan cleanup skipped live empty-queue snapshots, st…
t3chnaztea Jul 13, 2026
856888a
fix(search): one indexer timeout no longer discards every other index…
t3chnaztea Jul 13, 2026
e8b6f72
fix(search): zero configured indexers returned five fake mock release…
t3chnaztea Jul 13, 2026
729df06
fix(downloads): SABnzbd category filter hid tracked jobs reassigned t…
t3chnaztea Jul 13, 2026
d7d7f92
fix(qbittorrent): add URL Base support for reverse-proxied instances
schmitzkr Jul 19, 2026
aa5dbc9
fix: use MaM's tor[searchType] parameter for filter options
TheRealNoob Aug 9, 2026
d64ff3d
fix(qbittorrent): one unreadable torrent should not truncate the queu…
m4bard Aug 19, 2026
b225297
Fix ArgumentException on NZB titles containing quote characters
lisim Aug 20, 2026
872fe58
Fix SABnzbd downloads completing with no path set
lisim Aug 20, 2026
7f9457c
Group chapter files indexed as "N of M" into one unmatched-scan item
m4bard Aug 19, 2026
93f909c
Library import: keep every series membership, not just the first
m4bard Aug 19, 2026
2240d80
fix(import): actually embed the ASIN after an import
m4bard Aug 20, 2026
943a348
fix(scoring): resolve indexers once per batch, not once per result in…
m4bard Aug 21, 2026
6e128cc
fix(recovery): name every legacy journal blocking startup, not just t…
m4bard Aug 21, 2026
263f77e
fix(download-clients): one circuit breaker per client, not one for al…
m4bard Aug 21, 2026
6a4a63e
fix(downloads): resolve a client's path mappings once per batch, not …
m4bard Aug 21, 2026
618e0f4
fix(prowlarr-import): build proxy URLs from the base Prowlarr answere…
m4bard Aug 21, 2026
cd29725
Tolerate a differing leading article in book-boundary title matching
dny238 Aug 22, 2026
956494f
fix(imports): support weak storage moves (#864)
therobbiedavis Aug 22, 2026
35ced49
[skip ci] Bump version to 1.3.3
therobbiedavis Aug 22, 2026
d25b3e1
[skip ci] Merge pull request 874 - Bump version to 1.3.3
therobbiedavis Aug 22, 2026
c8b8ac7
manual-import: authorize companion files against the root folder, not…
m4bard Aug 19, 2026
29717e4
tests: pin the managed boundary the companion pass hands the ownershi…
m4bard Aug 19, 2026
2c60553
tests: adapt the boundary test to the publication path #864 introduced
m4bard Aug 24, 2026
c00057e
fix(downloads): translate a queue item's source files from the resolv…
m4bard Aug 24, 2026
c3bc76e
fix(scan): stop the Linux descriptor path leaking into metadata and size
m4bard Aug 14, 2026
ae14f3f
tests: follow the scan's metadata read onto the two-part file source
m4bard Aug 20, 2026
b5ad3d9
fix(naming): don't lose a series position that isn't a plain number
m4bard Aug 2, 2026
42dd035
fix(metadata): populate SeriesAsin from the Audnexus series record
m4bard Jul 14, 2026
08996b8
Read embedded ASIN/ISBN tags on scan and adopt them onto bare books
dny238 Aug 25, 2026
0c31a42
fix(files): physical-generation snapshots reject database-loaded obse…
kevinheneveld Aug 26, 2026
13e3926
Merge remote-tracking branch 'upstream/canary' into chore/sync-upstre…
krejko Aug 26, 2026
9d4e094
Merge upstream PR #849: fix(scan): stop the Linux descriptor path lea…
krejko Aug 26, 2026
54c989e
Merge upstream PR #902: fix(files): physical-generation snapshots rej…
krejko Aug 26, 2026
c0a4ec0
Merge upstream PR #784: Match scanned files when the on-disk name dro…
krejko Aug 26, 2026
bb87e13
Merge upstream PR #845: fix(scan): group chapter files indexed as "N …
krejko Aug 26, 2026
e8bf024
Merge upstream PR #866: fix(recovery): name every legacy journal bloc…
krejko Aug 26, 2026
e1bc616
Merge upstream PR #768: fix(metadata): populate SeriesAsin from the A…
krejko Aug 26, 2026
b26bc2a
Merge upstream PR #763: fix(naming): don't lose a series position tha…
krejko Aug 26, 2026
45a2d70
Merge upstream PR #843: fix(import): actually embed the ASIN after an…
krejko Aug 26, 2026
e4c1c38
Merge upstream PR #781: Read embedded ASIN/ISBN tags during scan
krejko Aug 26, 2026
8ecacfd
Merge upstream PR #847: fix(library-import): keep every series member…
krejko Aug 26, 2026
2cbe7d2
Merge upstream PR #756: fix(search): zero configured indexers returne…
krejko Aug 26, 2026
0a38b78
Merge upstream PR #757: fix(search): one indexer timeout no longer di…
krejko Aug 26, 2026
d680737
Merge upstream PR #755: fix(downloads): orphan cleanup skipped live e…
krejko Aug 26, 2026
35106c3
Merge upstream PR #759: fix(downloads): SABnzbd category filter hid t…
krejko Aug 26, 2026
2f039e0
Merge upstream PR #840: Fix SABnzbd downloads completing with no path…
krejko Aug 26, 2026
e3d49f9
Merge upstream PR #837: Fix ArgumentException on NZB titles containin…
krejko Aug 26, 2026
c519b71
Merge upstream PR #830: fix(qbittorrent): one unreadable torrent shou…
krejko Aug 26, 2026
3f43278
Merge upstream PR #772: fix(qbittorrent): add URL Base support for re…
krejko Aug 26, 2026
5d4a124
Merge upstream PR #806: fix: use MaM's tor[searchType] parameter for …
krejko Aug 26, 2026
3e540e3
Merge upstream PR #863: fix(scoring): resolve indexers once per batch…
krejko Aug 26, 2026
d64de28
Merge upstream PR #868: fix(download-clients): one circuit breaker pe…
krejko Aug 26, 2026
55c18f8
Merge upstream PR #871: fix(downloads): resolve a client's path mappi…
krejko Aug 26, 2026
98fe3e8
Merge upstream PR #876: fix(prowlarr-import): build proxy URLs from t…
krejko Aug 26, 2026
3d87fc3
Merge upstream PR #832: fix(manual-import): authorize companion files…
krejko Aug 26, 2026
0e05a1f
style: conform PR #806's switch arms to the repository formatter
krejko Aug 26, 2026
2f44ef2
Revert "Merge upstream PR #806: fix: use MaM's tor[searchType] parame…
krejko Aug 26, 2026
7013ede
fix: conform the merged upstream PRs to the repository's architecture…
krejko Aug 26, 2026
f3ca823
Revert "Merge upstream PR #871: fix(downloads): resolve a client's pa…
krejko Aug 26, 2026
ae63b0a
Revert "Merge upstream PR #843: fix(import): actually embed the ASIN …
krejko Aug 26, 2026
7af9255
Reapply "Merge upstream PR #843: fix(import): actually embed the ASIN…
krejko Aug 26, 2026
9ad93dd
Reapply "Merge upstream PR #871: fix(downloads): resolve a client's p…
krejko Aug 26, 2026
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
43 changes: 43 additions & 0 deletions fe/src/__tests__/DownloadClientFormModal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
38 changes: 37 additions & 1 deletion fe/src/__tests__/LibraryImportFooter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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)
})
})
21 changes: 21 additions & 0 deletions fe/src/__tests__/RootFoldersSettings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
119 changes: 117 additions & 2 deletions fe/src/__tests__/libraryImport.store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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)
})

Expand Down Expand Up @@ -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,
},
])
})
})
49 changes: 47 additions & 2 deletions fe/src/components/domain/audiobook/LibraryImportFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@
{{ store.failedCount }} lookup{{ store.failedCount === 1 ? '' : 's' }} failed - these were
not matched and will be retried on the next run
</div>

<div
v-if="usesCopyAndRetainMovePolicy"
class="move-policy-warning"
data-testid="move-policy-warning"
>
<PhWarning :size="16" />
<span>
This storage cannot prove durable file identity. Move will copy the selected files and
retain the source; Listenarr will not attempt source cleanup.
</span>
</div>
</div>

<div class="footer-center">
Expand Down Expand Up @@ -123,7 +135,10 @@ import { useToast } from '@/services/toastService'
import { useFilesystemReadinessStore } from '@/stores/filesystemReadiness'
import type { RootFolder } from '@/types'

const props = defineProps<{ folders: RootFolder[] }>()
const props = defineProps<{
folders: RootFolder[]
sourceFolder?: RootFolder
}>()

const store = useLibraryImportStore()
const filesystemReadinessStore = useFilesystemReadinessStore()
Expand All @@ -138,6 +153,18 @@ const destinationPath = computed(() => {
return props.folders.find((f) => f.id === destinationFolderId.value)?.path ?? ''
})

const destinationFolder = computed(() =>
props.folders.find((folder) => folder.id === destinationFolderId.value),
)

const usesCopyAndRetainMovePolicy = computed(() => {
if (store.action !== 'move') return false

return [props.sourceFolder, destinationFolder.value].some(
(folder) => folder?.canPublishNewFiles === true && folder.canMutateFilesystem === false,
)
})

const displayImportCount = computed(() =>
isImporting.value ? importingCount.value : store.selectedCount,
)
Expand All @@ -161,7 +188,7 @@ async function handleImport() {
isImporting.value = true

try {
const { imported, errors } = await store.importSelected(destinationPath.value)
const { imported, errors, warnings = [] } = await store.importSelected(destinationPath.value)

if (imported > 0) {
let msg = `${imported} book${imported !== 1 ? 's' : ''} imported`
Expand All @@ -175,6 +202,10 @@ async function handleImport() {
`${errors.length} item${errors.length !== 1 ? 's' : ''} failed - check logs`,
)
}

if (warnings.length > 0) {
toast.warning('Import completed with warnings', warnings.join('\n'))
}
} finally {
isImporting.value = false
importingCount.value = 0
Expand Down Expand Up @@ -263,6 +294,20 @@ async function handleImport() {
padding: 0.2rem 0.5rem;
}

.move-policy-warning {
display: flex;
align-items: center;
gap: 0.4rem;
max-width: 620px;
color: #fbbf24;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: 4px;
padding: 0.35rem 0.55rem;
font-size: 0.78rem;
white-space: normal;
}

.footer-center {
display: flex;
align-items: center;
Expand Down
Loading
Loading