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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ for a feature comparison and a settings import.
| **PAR2 verify & repair** | Automatic verification after download. Damaged files are rebuilt from recovery blocks in pure Rust — no external par2 binary. |
| **Archive extraction** | Automatic extraction of RAR, 7z, and ZIP archives after repair, multi-part sets included, with cleanup. |
| **Overlapped pipeline** | Independent downloads, PAR2 repair, and extraction can progress together under explicit per-stage worker limits. |
| **Web UI** | Queue, history with per-download insights, lifetime statistics, live logs, drag-and-drop NZB upload, and selectable themes. |
| **Web UI** | Queue, history with per-download insights and undoable deletes, lifetime statistics, live logs, drag-and-drop NZB upload, and selectable themes. |
| **Automation** | RSS feeds with regex rules, a watch folder, and a newsgroup browser with header search and threaded views. |
| **Media library** | WebDAV library that streams files on demand, straight from Usenet, without downloading first. |
| **REST API** | Full HTTP API with Swagger/OpenAPI documentation, plus a compatibility API for *arr applications. |
Expand Down
6 changes: 3 additions & 3 deletions apps/rustnzb/frontend/src/app/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function makeApp(postResult = new Subject<unknown>()) {
navigate: vi.fn(() => Promise.resolve(true)),
};
const pauseState = new PauseStateService();
const snack = { open: vi.fn() };
const snack = { open: vi.fn(), success: vi.fn(), info: vi.fn(), error: vi.fn(), undo: vi.fn() };
const addNzb = new AddNzbService();
const app = new App(
api as never,
Expand Down Expand Up @@ -186,7 +186,7 @@ describe('quick speed limit', () => {
expect(api.put).toHaveBeenCalledWith('/config/speed-limit', { speed_limit_bps: 5 * 1024 * 1024 });
expect(app.speedLimit()).toBe(5 * 1024 * 1024);
expect(app.speedMenuOpen).toBe(false);
expect(snack.open).toHaveBeenCalledWith('Speed limit set to 5.0 MB/s', 'Close', { duration: 2000 });
expect(snack.success).toHaveBeenCalledWith('Speed limit set to 5.0 MB/s');
});

it('converts a custom MB/s value and ignores empty input', () => {
Expand Down Expand Up @@ -380,7 +380,7 @@ describe('drop .nzb files anywhere', () => {
const { app, prefills, snack } = signedIn();
app.onWindowDrop(fileDrag('drop', [new File(['x'], 'movie.mkv')]));
expect(prefills).toEqual([]);
expect(snack.open).toHaveBeenCalled();
expect(snack.info).toHaveBeenCalled();
expect(app.announcer.assertive()).toContain('Only .nzb');
});

Expand Down
20 changes: 9 additions & 11 deletions apps/rustnzb/frontend/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ import { BrowserNotificationsService } from './core/services/browser-notificatio
import { ThemeService } from './core/services/theme.service';
import { AnnouncerService } from './core/services/announcer.service';
import { IconComponent } from './shared/icon.component';
import { MatSnackBar } from '@angular/material/snack-bar';
import { showHttpError } from './core/http/http-error';
import { ToastService } from './core/services/toast.service';

export function isDemoPath(pathname: string): boolean {
return pathname === '/demo' || pathname.startsWith('/demo/');
Expand Down Expand Up @@ -685,7 +685,7 @@ export class App implements OnInit, OnDestroy {
public widthMode: WidthModeService,
readonly theme: ThemeService,
pauseState: PauseStateService,
private snack: MatSnackBar,
private toast: ToastService,
private notifications: BrowserNotificationsService,
readonly announcer: AnnouncerService,
) {
Expand Down Expand Up @@ -801,12 +801,12 @@ export class App implements OnInit, OnDestroy {
next: () => {
this.speedLimit.set(bps);
this.speedMenuOpen = false;
this.snack.open(bps > 0 ? `Speed limit set to ${formatSpeed(bps)}` : 'Speed limit off', 'Close', {
duration: 2000,
});
this.toast.success(
bps > 0 ? `Speed limit set to ${formatSpeed(bps)}` : 'Speed limit off',
);
this.pollStatus();
},
error: (err) => showHttpError(this.snack, err, 'Failed to set speed limit'),
error: (err) => showHttpError(this.toast, err, 'Failed to set speed limit'),
});
}

Expand Down Expand Up @@ -864,9 +864,7 @@ export class App implements OnInit, OnDestroy {
e.preventDefault();
const files = nzbUploadFiles(e.dataTransfer?.files);
if (files.length === 0) {
this.snack.open('Only .nzb (or .zip/.rar/.7z/.gz) files can be added', 'Close', {
duration: 3000,
});
this.toast.info('Only .nzb (or .zip/.rar/.7z/.gz) files can be added');
this.announcer.announceError('Only .nzb (or .zip/.rar/.7z/.gz) files can be added');
return;
}
Expand Down Expand Up @@ -910,7 +908,7 @@ export class App implements OnInit, OnDestroy {
this.paused.set(wasPaused);
this.pollStatus();
showHttpError(
this.snack,
this.toast,
err,
wasPaused ? 'Failed to resume downloads' : 'Failed to pause downloads',
);
Expand All @@ -928,7 +926,7 @@ export class App implements OnInit, OnDestroy {
pauseFor(secs: number): void {
this.api.post(`/queue/pause-for?duration_secs=${secs}`).subscribe({
next: () => this.pollStatus(),
error: (err) => showHttpError(this.snack, err, 'Failed to pause downloads'),
error: (err) => showHttpError(this.toast, err, 'Failed to pause downloads'),
});
this.pauseMenuOpen = false;
}
Expand Down
7 changes: 5 additions & 2 deletions apps/rustnzb/frontend/src/app/core/services/api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@ export class ApiService {
return this.http.put<T>(`${this.baseUrl}${path}`, body, { headers: this.headers() });
}

delete<T>(path: string): Observable<T> {
return this.http.delete<T>(`${this.baseUrl}${path}`, { headers: this.headers() });
delete<T>(path: string, params?: Record<string, string>): Observable<T> {
return this.http.delete<T>(`${this.baseUrl}${path}`, {
headers: this.headers(),
params,
});
}

postForm<T>(path: string, body: FormData): Observable<T> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import '@angular/compiler';

import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';

import { ToastService, UNDO_WINDOW_MS } from './toast.service';

function service() {
const ref = { onAction: vi.fn(() => new Subject<void>()) };
const snack = { open: vi.fn(() => ref) };
return { toast: new ToastService(snack as never), snack, ref };
}

describe('ToastService', () => {
it('uses the house durations for success, info and error', () => {
const { toast, snack } = service();
toast.success('Saved');
toast.info('Somewhere');
toast.error('Broken');
expect(snack.open).toHaveBeenNthCalledWith(1, 'Saved', 'Close', { duration: 2000 });
expect(snack.open).toHaveBeenNthCalledWith(2, 'Somewhere', 'Close', { duration: 3000 });
expect(snack.open).toHaveBeenNthCalledWith(3, 'Broken', 'Close', { duration: 5000 });
});

it('offers Undo for the full undo window and runs the callback on action', () => {
const { toast, snack, ref } = service();
const onUndo = vi.fn();
toast.undo('Deleted "Release"', onUndo);
expect(snack.open).toHaveBeenCalledWith('Deleted "Release"', 'Undo', {
duration: UNDO_WINDOW_MS,
});
expect(onUndo).not.toHaveBeenCalled();
const action = ref.onAction.mock.results[0].value as Subject<void>;
action.next();
expect(onUndo).toHaveBeenCalledTimes(1);
});

it('leaves the action in place when the toast times out or is dismissed', () => {
const { toast, ref } = service();
const onUndo = vi.fn();
toast.undo('Deleted', onUndo);
const action = ref.onAction.mock.results[0].value as Subject<void>;
action.complete();
expect(onUndo).not.toHaveBeenCalled();
});

it('still exposes MatSnackBar open for showHttpError and custom actions', () => {
const { toast, snack } = service();
toast.open('Failed', 'Close', { duration: 5000 });
expect(snack.open).toHaveBeenCalledWith('Failed', 'Close', { duration: 5000 });
});
});
56 changes: 56 additions & 0 deletions apps/rustnzb/frontend/src/app/core/services/toast.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';

/** How long a destructive action's undo stays on screen. */
export const UNDO_WINDOW_MS = 10_000;

/**
* The app's one way to speak through a snackbar.
*
* Results that arrive without a focus change also go through
* `AnnouncerService`; this service only owns the visual toast, so pages keep
* their spoken message next to the one a sighted user sees.
*/
@Injectable({ providedIn: 'root' })
export class ToastService {
constructor(private readonly snack: MatSnackBar) {}

/** A routine success. Two seconds is the house default. */
success(message: string, duration = 2_000): void {
this.snack.open(message, 'Close', { duration });
}

/** An informational message that needs longer to read (a path, a hint). */
info(message: string, duration = 3_000): void {
this.snack.open(message, 'Close', { duration });
}

/** A failure. Errors stay up longer than successes. */
error(message: string, duration = 5_000): void {
this.snack.open(message, 'Close', { duration });
}

/**
* Report a destructive action that the server can reverse, with an `Undo`
* action. `onUndo` runs only when the user presses it; dismissing the toast
* or letting it time out leaves the action in place.
*/
undo(
message: string,
onUndo: () => void,
options: { duration?: number; undoLabel?: string } = {},
): void {
const ref = this.snack.open(message, options.undoLabel ?? 'Undo', {
duration: options.duration ?? UNDO_WINDOW_MS,
});
ref.onAction().subscribe(() => onUndo());
}

/**
* MatSnackBar's own shape, kept so `showHttpError()` and callers that pass a
* custom action work unchanged.
*/
open(message: string, action?: string, config?: { duration?: number }): unknown {
return this.snack.open(message, action, config);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import '@angular/compiler';

import { TestBed } from '@angular/core/testing';
import { MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ToastService } from '../../core/services/toast.service';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { of, throwError } from 'rxjs';

Expand All @@ -27,7 +27,13 @@ describe('GroupBrowserDialogComponent', () => {
subscribe: ReturnType<typeof vi.fn>;
unsubscribe: ReturnType<typeof vi.fn>;
};
let snack: { open: ReturnType<typeof vi.fn> };
let snack: {
open: ReturnType<typeof vi.fn>;
success: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
undo: ReturnType<typeof vi.fn>;
};
let dialogRef: { close: ReturnType<typeof vi.fn> };

beforeEach(() => {
Expand All @@ -37,11 +43,11 @@ describe('GroupBrowserDialogComponent', () => {
subscribe: vi.fn(() => of({})),
unsubscribe: vi.fn(() => of({})),
};
snack = { open: vi.fn() };
snack = { open: vi.fn(), success: vi.fn(), info: vi.fn(), error: vi.fn(), undo: vi.fn() };
dialogRef = { close: vi.fn() };
// The component imports MatSnackBarModule, which supplies its own MatSnackBar,
// so overrideProvider is needed to replace it (a plain provider is shadowed).
TestBed.overrideProvider(MatSnackBar, { useValue: snack });
// ToastService is providedIn: 'root', so overrideProvider is needed to
// replace it (a plain provider is shadowed).
TestBed.overrideProvider(ToastService, { useValue: snack });
TestBed.configureTestingModule({
imports: [GroupBrowserDialogComponent],
providers: [
Expand Down Expand Up @@ -99,7 +105,7 @@ describe('GroupBrowserDialogComponent', () => {
component.refresh();

expect(groupService.refresh).toHaveBeenCalledOnce();
expect(snack.open).toHaveBeenCalledWith('Refreshed 2 groups', 'Close', { duration: 3000 });
expect(snack.info).toHaveBeenCalledWith('Refreshed 2 groups');
expect(groupService.list).toHaveBeenCalledOnce();
expect(component.refreshing()).toBe(false);
});
Expand All @@ -125,9 +131,7 @@ describe('GroupBrowserDialogComponent', () => {
expect(groupService.subscribe).toHaveBeenCalledWith(1);
expect(groupService.unsubscribe).not.toHaveBeenCalled();
expect(row.subscribed).toBe(true);
expect(snack.open).toHaveBeenCalledWith('Subscribed to alt.test.1', 'Close', {
duration: 2000,
});
expect(snack.success).toHaveBeenCalledWith('Subscribed to alt.test.1');
expect(component.subPendingIds().has(1)).toBe(false);
});

Expand All @@ -140,9 +144,7 @@ describe('GroupBrowserDialogComponent', () => {

expect(groupService.unsubscribe).toHaveBeenCalledWith(3);
expect(row.subscribed).toBe(false);
expect(snack.open).toHaveBeenCalledWith('Unsubscribed from alt.test.3', 'Close', {
duration: 2000,
});
expect(snack.success).toHaveBeenCalledWith('Unsubscribed from alt.test.3');
});

it('keeps the star unchanged and clears the pending flag when a toggle fails', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { GroupService } from '../../core/services/group.service';
import { GroupRow } from '../../core/models/group.model';
import { IconComponent } from '../../shared/icon.component';
import { showHttpError } from '../../core/http/http-error';
import { ToastService } from '../../core/services/toast.service';

@Component({
selector: 'app-group-browser-dialog',
Expand Down Expand Up @@ -78,7 +79,11 @@ export class GroupBrowserDialogComponent implements OnInit {
refreshing = signal(false);
subPendingIds = signal<Set<number>>(new Set());

constructor(private svc: GroupService, private snack: MatSnackBar, private dialogRef: MatDialogRef<GroupBrowserDialogComponent>) {}
constructor(
private svc: GroupService,
private toast: ToastService,
private dialogRef: MatDialogRef<GroupBrowserDialogComponent>,
) {}

ngOnInit(): void { this.loadGroups(); }

Expand All @@ -99,10 +104,10 @@ export class GroupBrowserDialogComponent implements OnInit {
refresh(): void {
this.refreshing.set(true);
this.svc.refresh().subscribe({
next: r => { this.refreshing.set(false); this.snack.open(r.message, 'Close', { duration: 3000 }); this.loadGroups(); },
next: r => { this.refreshing.set(false); this.toast.info(r.message); this.loadGroups(); },
error: (e) => {
this.refreshing.set(false);
showHttpError(this.snack, e, 'Refresh failed');
showHttpError(this.toast, e, 'Refresh failed');
},
});
}
Expand All @@ -122,16 +127,14 @@ export class GroupBrowserDialogComponent implements OnInit {
next: () => {
g.subscribed = !wasSubscribed;
this.groups.set([...this.groups()]);
this.snack.open(
this.toast.success(
wasSubscribed ? `Unsubscribed from ${g.name}` : `Subscribed to ${g.name}`,
'Close',
{ duration: 2000 },
);
clearPending();
},
error: (err) => {
showHttpError(
this.snack,
this.toast,
err,
`Failed to ${wasSubscribed ? 'unsubscribe from' : 'subscribe to'} ${g.name}`,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function makeComponent(overrides: Record<string, ReturnType<typeof vi.fn>> = {})
markAllRead: vi.fn(() => of({ marked: 1 })),
...overrides,
};
const snack = { open: vi.fn() };
const snack = { open: vi.fn(), success: vi.fn(), info: vi.fn(), error: vi.fn(), undo: vi.fn() };
const dialog = { open: vi.fn() };
return { component: new GroupsViewComponent(service as unknown as GroupService, snack as never, dialog as never), service, snack };
}
Expand Down
Loading
Loading