Skip to content
Merged
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
7 changes: 5 additions & 2 deletions documentation/phase3/sabnzbd.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ interface SABnzbdConfig {

**Queue vs History:**
- **Queue:** Active downloads (downloading, queued, paused)
- **History:** Completed or failed downloads
- **History:** Post-processing (Verifying, Repairing, Extracting, Moving, Running), then Completed or Failed

**Monitoring Flow:**
1. Check queue for NZB ID
Expand All @@ -181,7 +181,9 @@ interface SABnzbdConfig {
- `Paused` → Manually paused
- `Extracting/Unpacking` → Post-processing extraction
- `Repairing/Verifying` → Post-processing par2 repair
- `Completed` → Successfully downloaded and extracted
- Queue item at 100% (any other status) → `extracting` (unified `processing`): the tail of file assembly (and direct unpack), not completion
- History post-processing statuses → not completed; monitoring keeps polling
- `Completed` (history) → Successfully downloaded and extracted; `storage` gives the download path
- `Failed` → Download or post-processing failed

## Remote Path Mapping
Expand Down Expand Up @@ -241,6 +243,7 @@ organizePath = PathMapper.transform(sabPath, config)
- Applying `reverseTransform()` when setting category path (outgoing)
- Applying `transform()` when processing completed downloads (incoming)
- Using same `PathMapper` utility as qBittorrent for consistency
**8. Requests failed at 100% before post-processing finished** - A queue item at 100% (the tail of file assembly, before SABnzbd moves the job to history for post-processing) mapped to `completed` with no storage path, so the monitor failed the request with "Download path not available". Fixed by mapping it to `extracting` in `getDownloadProgress()`; only a `Completed` history slot completes. Admin Retry treats `processing` as alive (`ALIVE_STATUSES` in `retry-download/route.ts`), so it resumes monitoring instead of re-adding the NZB.

## Automatic Cleanup

Expand Down
1 change: 1 addition & 0 deletions src/app/api/admin/requests/[id]/retry-download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const ALIVE_STATUSES = new Set([
'queued',
'paused',
'checking',
'processing', // e.g. a 100% SABnzbd item still finishing; re-adding would duplicate it
'seeding',
'completed',
]);
Expand Down
6 changes: 5 additions & 1 deletion src/lib/integrations/sabnzbd.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,7 +927,11 @@ export class SABnzbdService implements IDownloadClient {
} else if (statusLower.includes('repairing') || statusLower.includes('verifying')) {
state = 'repairing';
} else if (percent >= 1.0) {
state = 'completed';
// 100% in the queue is the tail of file assembly (and direct unpack). SABnzbd
// then moves the job to history for post-processing (Verifying, Repairing,
// Extracting, Moving, Running); only a Completed history slot has the final
// storage path, so this is not completion yet.
state = 'extracting';
}

return {
Expand Down
20 changes: 19 additions & 1 deletion tests/api/retry-download-guard.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
* Documentation: documentation/phase3/download-clients.md
*
* The stored link is only checked when the download is re-added; resuming
* monitoring of a live torrent never uses it.
* monitoring of a live torrent never uses it. A download the client still
* reports as processing counts as live.
*/

import { beforeEach, describe, expect, it, vi } from 'vitest';
Expand Down Expand Up @@ -51,6 +52,23 @@ describe('POST /api/admin/requests/[id]/retry-download', () => {
expect(jobQueueMock.addDownloadJob).not.toHaveBeenCalled();
});

it('resumes monitoring a SABnzbd download that is still processing instead of re-adding it', async () => {
// A 100% SABnzbd queue item reports processing; re-adding it would give SABnzbd a duplicate NZB.
prismaMock.downloadHistory.findFirst.mockResolvedValue({
id: 'dh-2', magnetLink: 'http://prowlarr-new:9696/1/download?link=abc', downloadClient: 'sabnzbd',
downloadClientId: 'SABnzbd_nzo_1', torrentName: 'Release', indexerName: 'Indexer', indexerId: 1,
torrentSizeBytes: null, seeders: null, leechers: null, torrentHash: null, torrentUrl: null,
});
clientMock.getDownload.mockResolvedValue({ status: 'processing', progress: 1 });
const { POST } = await import('@/app/api/admin/requests/[id]/retry-download/route');
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-1' }) });

expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ path: 'resumed_monitoring' });
expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith('req-1', 'dh-2', 'SABnzbd_nzo_1', 'sabnzbd', 0);
expect(jobQueueMock.addDownloadJob).not.toHaveBeenCalled();
});

it('rejects re-adding a stored link that is not a Prowlarr download link or magnet', async () => {
clientMock.getDownload.mockResolvedValue(null);
const { POST } = await import('@/app/api/admin/requests/[id]/retry-download/route');
Expand Down
70 changes: 68 additions & 2 deletions tests/integrations/sabnzbd.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ describe('SABnzbdService', () => {
expect(extracting.state).toBe('extracting');
});

it('maps completed status when percentage is 100', () => {
it('maps a 100 percent queue item to extracting, not completed', () => {
const service = new SABnzbdService('http://sab', 'key');
const progress = service.getDownloadProgress({
nzbId: 'nzb-7',
Expand All @@ -394,10 +394,76 @@ describe('SABnzbdService', () => {
priority: 'Normal',
});

expect(progress.state).toBe('completed');
expect(progress.state).toBe('extracting');
expect(progress.percent).toBe(1);
});

it('reports completed only once history says Completed, with its storage path', async () => {
// 100% in the queue is the tail of file assembly. SAB then moves the job to history,
// where post-processing (Verifying, Repairing, Extracting, Moving, Running) is reported;
// only a Completed history slot is done and has the final path.
const finalPath = '/downloads/readmeabook/Post Processing Book';
let historyStatus: string | null = null; // null: still in the queue
clientMock.get.mockImplementation(async (_url: string, config: { params: { mode: string } }) => {
if (config.params.mode === 'queue') {
return {
data: {
queue: {
slots: historyStatus !== null ? [] : [{
nzo_id: 'nzb-pp',
filename: 'Post Processing Book',
mb: '100',
mbleft: '0',
percentage: '100',
status: 'Downloading',
timeleft: '0:00:00',
cat: 'readmeabook',
}],
},
},
};
}
if (config.params.mode === 'history') {
return {
data: {
history: {
slots: historyStatus === null ? [] : [{
nzo_id: 'nzb-pp',
name: 'Post Processing Book',
category: 'readmeabook',
status: historyStatus,
bytes: '104857600',
// Even a known path must not complete the job before SAB says Completed.
storage: historyStatus === 'Extracting' ? '' : finalPath,
completed: historyStatus === 'Completed' ? '1700000000' : '0',
}],
},
},
};
}
throw new Error(`Unexpected SABnzbd mode: ${config.params.mode}`);
});

const service = new SABnzbdService('http://sab', 'key');

// Still in the queue at 100%: keep monitoring, there is no path to organize yet.
const assembling = await service.getDownload('nzb-pp');
expect(assembling).toMatchObject({ status: 'processing', progress: 1 });
expect(assembling?.downloadPath).toBeUndefined();

// Post-processing in history: neither completed nor failed, so the monitor keeps polling.
for (const status of ['Extracting', 'Moving']) {
historyStatus = status;
const postProcessing = await service.getDownload('nzb-pp');
expect(postProcessing, status).not.toBeNull();
expect(['completed', 'failed'], status).not.toContain(postProcessing?.status);
}

historyStatus = 'Completed';
const done = await service.getDownload('nzb-pp');
expect(done).toMatchObject({ status: 'completed', downloadPath: finalPath });
});

it('creates the default category when missing', async () => {
clientMock.get
.mockResolvedValueOnce({
Expand Down
Loading