Skip to content
Open
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
61 changes: 59 additions & 2 deletions src/Client.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string> | undefined;
_completionListeners: ((item: NZBQueueItem, success: boolean) => void)[] = [];

constructor(autoStart = true) {
// Initialize with the active downloader
this._downloader = getActiveDownloader().then((opts) => {
Expand All @@ -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();
});

Expand Down Expand Up @@ -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
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down
242 changes: 242 additions & 0 deletions src/__tests__/Client.test.ts
Original file line number Diff line number Diff line change
@@ -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<NZBResult> {
return { success: true };
}
async getCategories(): Promise<string[]> {
return [];
}
async setMaxSpeed(): Promise<NZBResult> {
return { success: true };
}
async getHistory(): Promise<NZBQueueItem[]> {
return this.historyResult;
}
async getQueue(): Promise<NZBQueue> {
return this.queueResult;
}
async pauseQueue(): Promise<NZBResult> {
return { success: true };
}
async resumeQueue(): Promise<NZBResult> {
return { success: true };
}
async addUrl(): Promise<NZBAddUrlResult> {
return { success: true };
}
async addFile(): Promise<NZBAddUrlResult> {
return { success: true };
}
async removeId(): Promise<NZBResult> {
return { success: true };
}
async removeItem(): Promise<NZBResult> {
return { success: true };
}
async pauseId(): Promise<NZBResult> {
return { success: true };
}
async pauseItem(): Promise<NZBResult> {
return { success: true };
}
async resumeId(): Promise<NZBResult> {
return { success: true };
}
async resumeItem(): Promise<NZBResult> {
return { success: true };
}
async test(): Promise<NZBResult> {
return { success: true };
}
}

function makeItem(id: string, overrides: Partial<NZBQueueItem> = {}): 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();
});
});
Loading