diff --git a/documentation/phase3/sabnzbd.md b/documentation/phase3/sabnzbd.md index 9b17f304..8f5eec85 100644 --- a/documentation/phase3/sabnzbd.md +++ b/documentation/phase3/sabnzbd.md @@ -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 @@ -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 @@ -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 diff --git a/src/app/api/admin/requests/[id]/retry-download/route.ts b/src/app/api/admin/requests/[id]/retry-download/route.ts index 78ddcb1d..8ee66d8b 100644 --- a/src/app/api/admin/requests/[id]/retry-download/route.ts +++ b/src/app/api/admin/requests/[id]/retry-download/route.ts @@ -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', ]); diff --git a/src/lib/integrations/sabnzbd.service.ts b/src/lib/integrations/sabnzbd.service.ts index 40df08b2..7d043f85 100644 --- a/src/lib/integrations/sabnzbd.service.ts +++ b/src/lib/integrations/sabnzbd.service.ts @@ -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 { diff --git a/tests/api/retry-download-guard.routes.test.ts b/tests/api/retry-download-guard.routes.test.ts index de142245..3c173920 100644 --- a/tests/api/retry-download-guard.routes.test.ts +++ b/tests/api/retry-download-guard.routes.test.ts @@ -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'; @@ -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'); diff --git a/tests/integrations/sabnzbd.service.test.ts b/tests/integrations/sabnzbd.service.test.ts index ba385db7..f95b63fc 100644 --- a/tests/integrations/sabnzbd.service.test.ts +++ b/tests/integrations/sabnzbd.service.test.ts @@ -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', @@ -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({