From 7b346501685cab7eda2d335c97cc6b51b92f67dc Mon Sep 17 00:00:00 2001 From: ROtto84 Date: Thu, 23 Jul 2026 22:09:48 -0400 Subject: [PATCH] Feature/notification --- src/Client.ts | 61 ++++- src/__tests__/Client.test.ts | 242 ++++++++++++++++++ src/downloader/NZBGet.ts | 59 ++++- src/downloader/SABnzbd.ts | 33 ++- .../__tests__/NZBGet.history.test.ts | 96 +++++++ .../__tests__/SABnzbd.history.test.ts | 82 ++++++ src/downloader/index.ts | 3 + src/entrypoints/background.ts | 31 +++ src/entrypoints/options/Options.tsx | 4 +- 9 files changed, 603 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/Client.test.ts create mode 100644 src/downloader/__tests__/NZBGet.history.test.ts create mode 100644 src/downloader/__tests__/SABnzbd.history.test.ts diff --git a/src/Client.ts b/src/Client.ts index 41400ae..543407a 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -1,4 +1,10 @@ -import { NZBAddOptions, NZBQueue, NZBQueueItem, type Downloader } from '~/downloader'; +import { + NZBAddOptions, + NZBQueue, + NZBQueueItem, + DefaultNZBQueueItem, + type Downloader, +} from '~/downloader'; import { SABnzbd } from '~/downloader/SABnzbd'; import { NZBGet } from '~/downloader/NZBGet'; import { @@ -48,6 +54,10 @@ export class Client { _listeners: ((arg0: Client) => void)[] = []; _refreshing: boolean = false; + // Tracks queue item ids between refreshes so we can detect completions + _previousIds: Set | undefined; + _completionListeners: ((item: NZBQueueItem, success: boolean) => void)[] = []; + constructor(autoStart = true) { // Initialize with the active downloader this._downloader = getActiveDownloader().then((opts) => { @@ -59,6 +69,8 @@ export class Client { this._optsWatcher = watchActiveDownloader((opts) => { this._syncDownloader = createDownloader(opts); this._downloader = Promise.resolve(this._syncDownloader); + // Avoid false completion notifications when switching downloaders + this._previousIds = undefined; this.refresh(); }); @@ -99,7 +111,9 @@ export class Client { */ async refresh() { this._refreshing = true; - this._queue = await (await this.getDownloader())?.getQueue(); + const downloader = await this.getDownloader(); + this._queue = await downloader?.getQueue(); + await this.checkCompletions(downloader); this.onRefresh(); setTimeout(() => (this._refreshing = false), 500); // Actual refresh is too fast, so delay } @@ -108,6 +122,35 @@ export class Client { return this._refreshing; } + /** + * Compare the current queue against the queue from the previous refresh to + * detect items that have left the queue (completed or failed), then look + * them up in the downloader's history to determine the final status and + * notify any completion listeners. + */ + async checkCompletions(downloader?: Downloader) { + const currentIds = new Set(this.queue.map((item) => item.id)); + + // Skip the very first refresh (or a refresh right after switching + // downloaders) so we don't fire notifications for items that already + // finished before we started watching. + if (this._previousIds && downloader) { + const finishedIds = [...this._previousIds].filter((id) => !currentIds.has(id)); + + if (finishedIds.length) { + const history = await downloader.getHistory().catch(() => []); + + for (const id of finishedIds) { + const historyItem = history.find((item) => item.id === id); + const success = historyItem ? historyItem.status !== 'Failed' : true; + this.onCompletion(historyItem ?? { ...DefaultNZBQueueItem, id }, success); + } + } + } + + this._previousIds = currentIds; + } + // Refresh timer async start(overrideInterval?: number) { @@ -150,6 +193,20 @@ export class Client { this._listeners.forEach((l) => l(this)); } + // Completion listeners (fired when a queue item finishes downloading) + + addCompletionListener(listener: (item: NZBQueueItem, success: boolean) => void) { + this._completionListeners.push(listener); + } + + removeCompletionListener(listener: (item: NZBQueueItem, success: boolean) => void) { + this._completionListeners = this._completionListeners.filter((l) => l !== listener); + } + + onCompletion(item: NZBQueueItem, success: boolean) { + this._completionListeners.forEach((l) => l(item, success)); + } + // Queue properties (call refresh to update) get name() { diff --git a/src/__tests__/Client.test.ts b/src/__tests__/Client.test.ts new file mode 100644 index 0000000..f9795ce --- /dev/null +++ b/src/__tests__/Client.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Client } from '../Client'; +import { + Downloader, + DefaultNZBQueue, + DefaultNZBQueueItem, + type NZBQueue, + type NZBQueueItem, + type NZBResult, + type NZBAddUrlResult, +} from '../downloader'; +import { DownloaderType, type DownloaderOptions } from '../store'; + +/** + * A minimal in-memory Downloader used to drive Client's refresh/completion + * logic without hitting a real SABnzbd/NZBGet instance. + */ +class FakeDownloader extends Downloader { + queueResult: NZBQueue = { ...DefaultNZBQueue, queue: [] }; + historyResult: NZBQueueItem[] = []; + + async call(): Promise { + return { success: true }; + } + async getCategories(): Promise { + return []; + } + async setMaxSpeed(): Promise { + return { success: true }; + } + async getHistory(): Promise { + return this.historyResult; + } + async getQueue(): Promise { + return this.queueResult; + } + async pauseQueue(): Promise { + return { success: true }; + } + async resumeQueue(): Promise { + return { success: true }; + } + async addUrl(): Promise { + return { success: true }; + } + async addFile(): Promise { + return { success: true }; + } + async removeId(): Promise { + return { success: true }; + } + async removeItem(): Promise { + return { success: true }; + } + async pauseId(): Promise { + return { success: true }; + } + async pauseItem(): Promise { + return { success: true }; + } + async resumeId(): Promise { + return { success: true }; + } + async resumeItem(): Promise { + return { success: true }; + } + async test(): Promise { + return { success: true }; + } +} + +function makeItem(id: string, overrides: Partial = {}): NZBQueueItem { + return { ...DefaultNZBQueueItem, id, name: `Item ${id}`, ...overrides }; +} + +/** + * Build a Client instance wired directly to a FakeDownloader, bypassing the + * async store-driven downloader resolution (and the singleton) so tests are + * fast, isolated, and don't depend on storage. + */ +function makeClient(): { client: Client; downloader: FakeDownloader } { + const client = new Client(false); // Don't auto-start the refresh timer + const downloader = new FakeDownloader({ + Type: DownloaderType.SABnzbd, + ApiUrl: 'http://localhost/api', + } as DownloaderOptions); + + // These assignments run synchronously, before the constructor's dangling + // getActiveDownloader() promise can resolve and touch _syncDownloader. + client._syncDownloader = downloader; + client._downloader = Promise.resolve(downloader); + + return { client, downloader }; +} + +describe('Client completion detection', () => { + it('does not fire completion listeners on the very first refresh', async () => { + const { client, downloader } = makeClient(); + downloader.queueResult = { ...DefaultNZBQueue, queue: [makeItem('1')] }; + + const listener = vi.fn(); + client.addCompletionListener(listener); + + await client.refresh(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('fires a success completion when a queued item completes', async () => { + const { client, downloader } = makeClient(); + const listener = vi.fn(); + client.addCompletionListener(listener); + + downloader.queueResult = { + ...DefaultNZBQueue, + queue: [makeItem('1'), makeItem('2')], + }; + await client.refresh(); // seed previous ids, no notification expected + + downloader.queueResult = { ...DefaultNZBQueue, queue: [makeItem('2')] }; // '1' left the queue + downloader.historyResult = [makeItem('1', { status: 'Completed' })]; + await client.refresh(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ id: '1', status: 'Completed' }), + true, + ); + }); + + it('fires a failure completion when history reports a failed download', async () => { + const { client, downloader } = makeClient(); + const listener = vi.fn(); + client.addCompletionListener(listener); + + downloader.queueResult = { ...DefaultNZBQueue, queue: [makeItem('1')] }; + await client.refresh(); + + downloader.queueResult = { ...DefaultNZBQueue, queue: [] }; + downloader.historyResult = [makeItem('1', { status: 'Failed' })]; + await client.refresh(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ id: '1', status: 'Failed' }), + false, + ); + }); + + it('assumes success when the finished item has no matching history entry', async () => { + const { client, downloader } = makeClient(); + const listener = vi.fn(); + client.addCompletionListener(listener); + + downloader.queueResult = { ...DefaultNZBQueue, queue: [makeItem('1')] }; + await client.refresh(); + + downloader.queueResult = { ...DefaultNZBQueue, queue: [] }; + downloader.historyResult = []; // Downloader has no history support / entry missing + await client.refresh(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ id: '1' }), true); + }); + + it('does not fire for items still present in the queue', async () => { + const { client, downloader } = makeClient(); + const listener = vi.fn(); + client.addCompletionListener(listener); + + downloader.queueResult = { ...DefaultNZBQueue, queue: [makeItem('1')] }; + await client.refresh(); + + // Same item, still downloading (e.g. percentage changed) + downloader.queueResult = { + ...DefaultNZBQueue, + queue: [makeItem('1', { percentage: 50 })], + }; + await client.refresh(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('fires once per item when multiple items finish in the same refresh', async () => { + const { client, downloader } = makeClient(); + const listener = vi.fn(); + client.addCompletionListener(listener); + + downloader.queueResult = { + ...DefaultNZBQueue, + queue: [makeItem('1'), makeItem('2'), makeItem('3')], + }; + await client.refresh(); + + downloader.queueResult = { ...DefaultNZBQueue, queue: [makeItem('3')] }; + downloader.historyResult = [ + makeItem('1', { status: 'Completed' }), + makeItem('2', { status: 'Failed' }), + ]; + await client.refresh(); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ id: '1' }), true); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ id: '2' }), false); + }); + + it('passes through a failure reason from history, when present', async () => { + const { client, downloader } = makeClient(); + const listener = vi.fn(); + client.addCompletionListener(listener); + + downloader.queueResult = { ...DefaultNZBQueue, queue: [makeItem('1')] }; + await client.refresh(); + + downloader.queueResult = { ...DefaultNZBQueue, queue: [] }; + downloader.historyResult = [ + makeItem('1', { status: 'Failed', message: 'Par verification failed' }), + ]; + await client.refresh(); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ id: '1', message: 'Par verification failed' }), + false, + ); + }); + + it('stops notifying a removed listener', async () => { + const { client, downloader } = makeClient(); + const listener = vi.fn(); + client.addCompletionListener(listener); + client.removeCompletionListener(listener); + + downloader.queueResult = { ...DefaultNZBQueue, queue: [makeItem('1')] }; + await client.refresh(); + + downloader.queueResult = { ...DefaultNZBQueue, queue: [] }; + downloader.historyResult = [makeItem('1', { status: 'Completed' })]; + await client.refresh(); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/src/downloader/NZBGet.ts b/src/downloader/NZBGet.ts index f70ad94..0bb97d6 100644 --- a/src/downloader/NZBGet.ts +++ b/src/downloader/NZBGet.ts @@ -32,6 +32,36 @@ export interface NZBGetResult extends NZBResult { version?: string; } +// NZBGet reports history status as a combined code like 'FAILURE/PAR' or +// 'WARNING/DAMAGED'. Map the reason half to something readable for notifications. +// See https://nzbget.com/documentation/api/#status-codes +const NZBGET_STATUS_REASONS: Record = { + PAR: 'Par verification failed', + UNPACK: 'Unpacking failed', + MOVE: 'Could not move files to the destination folder', + HEALTH: 'Download health check failed', + SPACE: 'Not enough disk space', + PASSWORD: 'Archive is password protected', + DAMAGED: 'Downloaded files are damaged', + BADPARENT: 'A duplicate/parent item failed', +}; + +function describeNzbgetHistoryStatus(rawStatus: string): { + status: string; + message?: string; +} { + const [kind, reason] = rawStatus.split('/'); + const success = /^success/i.test(kind); + + if (success) return { status: 'Completed' }; + + const message = reason + ? (NZBGET_STATUS_REASONS[reason.toUpperCase()] ?? `${ucFirst(kind)}: ${reason}`) + : ucFirst(kind); + + return { status: 'Failed', message }; +} + export class NZBGet extends Downloader { static generateApiUrlSuggestions(url: string): string[] { return super.generateApiUrlSuggestions(url, ['6789'], ['', 'jsonrpc']); @@ -105,7 +135,34 @@ export class NZBGet extends Downloader { } async getHistory(): Promise { - return []; + // See https://nzbget.com/api/method/history + // param: whether to include hidden (duplicate) history items + const nzbResult = await this.call('history', [false]); + + if (!nzbResult.success) return []; + + const slots = (nzbResult.result ?? []) as Record[]; + + return slots.map((slot) => { + const sizeBytes = Math.floor((slot['FileSizeMB'] || 0) * Megabyte); + const rawStatus = String(slot['Status'] ?? ''); + const { status, message } = describeNzbgetHistoryStatus(rawStatus); + + return { + ...DefaultNZBQueueItem, + id: String(slot['NZBID']), + status, + name: (slot['NZBNicename'] ?? slot['Name']) as string, + category: slot['Category'] as string, + size: humanSize(sizeBytes), + sizeBytes, + sizeRemaining: humanSize(0), + sizeRemainingBytes: 0, + timeRemaining: '∞', + percentage: 100, + message, + } as NZBQueueItem; + }); } async getQueue(): Promise { diff --git a/src/downloader/SABnzbd.ts b/src/downloader/SABnzbd.ts index 34e55ee..bca0836 100644 --- a/src/downloader/SABnzbd.ts +++ b/src/downloader/SABnzbd.ts @@ -103,8 +103,37 @@ export class SABnzbd extends Downloader { return res; } - async getHistory(): Promise { - return []; + async getHistory(options: Record = {}): Promise { + // See https://sabnzbd.org/wiki/advanced/api#history + const nzbResult = await this.call('history', { limit: 25, ...options }); + + if (!nzbResult.success) return []; + + const result = nzbResult.result! as Record; + const slots = (result.slots ?? []) as Record[]; + + return slots.map((slot) => { + const sizeBytes = Math.floor(Number(slot.bytes) || 0); + // SABnzbd reports 'Completed' or 'Failed' (amongst in-progress states we + // don't expect to see in history), normalize just in case. + const status = /fail/i.test(slot.status) ? 'Failed' : 'Completed'; + const message = status === 'Failed' ? slot.fail_message?.trim() || undefined : undefined; + + return { + ...DefaultNZBQueueItem, + id: slot.nzo_id, + status, + name: slot.name, + category: slot.category, + size: humanSize(sizeBytes), + sizeBytes, + sizeRemaining: humanSize(0), + sizeRemainingBytes: 0, + timeRemaining: '∞', + percentage: 100, + message, + }; + }); } async getQueue(): Promise { diff --git a/src/downloader/__tests__/NZBGet.history.test.ts b/src/downloader/__tests__/NZBGet.history.test.ts new file mode 100644 index 0000000..ce03096 --- /dev/null +++ b/src/downloader/__tests__/NZBGet.history.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { DefaultDownloaderOptions, DownloaderType } from '~/store'; +import { NZBGet } from '../NZBGet'; + +const downloaderOptions = { + ...DefaultDownloaderOptions, + Name: 'Test NZBGet', + Type: DownloaderType.NZBGet, + ApiUrl: 'http://localhost:6789/jsonrpc', +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('NZBGet.getHistory', () => { + it('maps a SUCCESS status to Completed with no message', async () => { + const client = new NZBGet(downloaderOptions); + vi.spyOn(client, 'call').mockResolvedValue({ + success: true, + result: [ + { + NZBID: 101, + Status: 'SUCCESS/ALL', + NZBNicename: 'Ubuntu.24.04.Desktop.ISO', + Category: 'software', + FileSizeMB: 4700, + }, + ], + }); + + const history = await client.getHistory(); + + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + id: '101', + status: 'Completed', + name: 'Ubuntu.24.04.Desktop.ISO', + message: undefined, + }); + }); + + it('maps a known FAILURE reason code to a friendly message', async () => { + const client = new NZBGet(downloaderOptions); + vi.spyOn(client, 'call').mockResolvedValue({ + success: true, + result: [ + { + NZBID: 102, + Status: 'FAILURE/PAR', + NZBNicename: 'Some.Movie.2026', + Category: 'movies', + FileSizeMB: 1200, + }, + ], + }); + + const history = await client.getHistory(); + + expect(history[0]).toMatchObject({ + id: '102', + status: 'Failed', + message: 'Par verification failed', + }); + }); + + it('falls back to a readable label for unmapped reason codes', async () => { + const client = new NZBGet(downloaderOptions); + vi.spyOn(client, 'call').mockResolvedValue({ + success: true, + result: [ + { + NZBID: 103, + Status: 'WARNING/SOMETHING_NEW', + NZBNicename: 'Odd.Case', + Category: '', + FileSizeMB: 10, + }, + ], + }); + + const history = await client.getHistory(); + + expect(history[0].status).toBe('Failed'); + expect(history[0].message).toBe('Warning: SOMETHING_NEW'); + }); + + it('returns an empty array when the call fails', async () => { + const client = new NZBGet(downloaderOptions); + vi.spyOn(client, 'call').mockResolvedValue({ success: false, error: 'Timed out' }); + + const history = await client.getHistory(); + + expect(history).toEqual([]); + }); +}); diff --git a/src/downloader/__tests__/SABnzbd.history.test.ts b/src/downloader/__tests__/SABnzbd.history.test.ts new file mode 100644 index 0000000..3c268c9 --- /dev/null +++ b/src/downloader/__tests__/SABnzbd.history.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { DefaultDownloaderOptions, DownloaderType } from '~/store'; +import { SABnzbd } from '../SABnzbd'; + +const downloaderOptions = { + ...DefaultDownloaderOptions, + Name: 'Test SAB', + Type: DownloaderType.SABnzbd, + ApiUrl: 'http://localhost:7357/api', + ApiKey: 'testkey', +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('SABnzbd.getHistory', () => { + it('maps a completed slot with no message', async () => { + const client = new SABnzbd(downloaderOptions); + vi.spyOn(client, 'call').mockResolvedValue({ + success: true, + result: { + slots: [ + { + nzo_id: 'SABnzbd_nzo_1', + status: 'Completed', + name: 'Ubuntu.24.04.Desktop.ISO', + category: 'software', + bytes: '4700000000', + fail_message: '', + }, + ], + }, + }); + + const history = await client.getHistory(); + + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + id: 'SABnzbd_nzo_1', + status: 'Completed', + name: 'Ubuntu.24.04.Desktop.ISO', + message: undefined, + }); + }); + + it('maps a failed slot and surfaces the fail_message as the reason', async () => { + const client = new SABnzbd(downloaderOptions); + vi.spyOn(client, 'call').mockResolvedValue({ + success: true, + result: { + slots: [ + { + nzo_id: 'SABnzbd_nzo_2', + status: 'Failed', + name: 'Some.Movie.2026', + category: 'movies', + bytes: '0', + fail_message: 'Unpacking failed, write error or disk is full?', + }, + ], + }, + }); + + const history = await client.getHistory(); + + expect(history[0]).toMatchObject({ + id: 'SABnzbd_nzo_2', + status: 'Failed', + message: 'Unpacking failed, write error or disk is full?', + }); + }); + + it('returns an empty array when the call fails', async () => { + const client = new SABnzbd(downloaderOptions); + vi.spyOn(client, 'call').mockResolvedValue({ success: false, error: 'Timed out' }); + + const history = await client.getHistory(); + + expect(history).toEqual([]); + }); +}); diff --git a/src/downloader/index.ts b/src/downloader/index.ts index 9f9e724..aa985bf 100644 --- a/src/downloader/index.ts +++ b/src/downloader/index.ts @@ -80,6 +80,9 @@ export interface NZBQueueItem { sizeRemainingBytes: number; timeRemaining: string; percentage: number; + // Optional detail set by getHistory(), eg. the reason a download failed. + // Not populated by getQueue() since active queue items don't have this yet. + message?: string; } export const DefaultNZBQueue: NZBQueue = { diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index 5955157..9fdd073 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -1,9 +1,11 @@ import { defineBackground } from 'wxt/sandbox'; import { Client } from '~/Client'; +import { icons } from '~/assets'; import { Logger, LogStorage } from '~/logger'; import { getOptions, DefaultOptions, DownloaderType } from '~/store'; import { setMenuIcon } from '~/utils'; +import type { NZBQueueItem } from '~/downloader'; export default defineBackground(() => { const logger = new Logger('Background'); @@ -121,4 +123,33 @@ export default defineBackground(() => { setMenuIcon('inactive', client.status); } }); + + // Show a browser notification when a download completes or fails + Client.getInstance().addCompletionListener( + async (item: NZBQueueItem, success: boolean) => { + const { EnableNotifications } = await getOptions(); + if (!EnableNotifications) return; + + const notificationId = `nzbunity-${item.id || Date.now()}`; + + browser.notifications.create(notificationId, { + type: 'basic', + iconUrl: success ? icons.icon_nzb_64_green : icons.icon_nzb_64_red, + title: success ? 'Download Complete' : 'Download Failed', + message: item.name || 'A download has finished.', + // Second, dimmer line in the notification; used for the failure reason + contextMessage: !success ? item.message || 'Unknown error' : undefined, + }); + + logger.debug(`Notification: ${notificationId}`, item, success); + }, + ); + + // Clicking a notification opens the downloader's web UI + browser.notifications.onClicked.addListener((notificationId: string) => { + if (notificationId.startsWith('nzbunity-')) { + Client.getInstance().openWebUI(); + browser.notifications.clear(notificationId); + } + }); }); diff --git a/src/entrypoints/options/Options.tsx b/src/entrypoints/options/Options.tsx index 6400281..00ae40c 100644 --- a/src/entrypoints/options/Options.tsx +++ b/src/entrypoints/options/Options.tsx @@ -282,7 +282,6 @@ function Options() { - {/* Currently does nothing, so let's not show it
- */}