Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
739e5ed
fix(postproc): deliver archives for download-only and repair-only jobs
xbmc4lyfe Oct 9, 2026
a54bb8d
fix(web): honour sort field and direction in queue sort
xbmc4lyfe Oct 9, 2026
a8ca12f
fix(web): count unknown ids as failed in bulk queue actions
xbmc4lyfe Oct 9, 2026
bedecf3
fix(frontend): surface server error messages on failed mutations
xbmc4lyfe Oct 9, 2026
6bbe3ce
fix(web): make history retry idempotent per history entry
xbmc4lyfe Oct 9, 2026
5beb82e
docs(config): document max_active_downloads = 0 as unlimited
xbmc4lyfe Oct 9, 2026
302b20e
fix(web): wake the RSS monitor on config changes and poll feeds per i…
xbmc4lyfe Oct 9, 2026
e86b3e8
fix(web): enforce a 60 s floor on RSS feed poll intervals
xbmc4lyfe Oct 9, 2026
ae524c3
fix(web): treat an RSS rule with no feeds as applying to all feeds
xbmc4lyfe Oct 9, 2026
dcf0464
fix(server): layer the DAV extension in build_router so tests match prod
xbmc4lyfe Oct 9, 2026
36b2087
fix(api): 404/409 for unknown and duplicate config entries; validate …
xbmc4lyfe Oct 9, 2026
cbd4272
fix(api): 400/409 instead of 500 for bad input on add, browse and RSS…
xbmc4lyfe Oct 9, 2026
9d8609e
fix(api): validate imported categories on SABnzbd import apply
xbmc4lyfe Oct 9, 2026
41c13bc
fix(queue): report disk-space holds and lift them when space frees
xbmc4lyfe Oct 9, 2026
b07ea18
fix(api): reject unknown and out-of-range fields on PUT /config/general
xbmc4lyfe Oct 9, 2026
dc86cf5
fix(sab): parse inline job passwords from job and file names
xbmc4lyfe Oct 9, 2026
73837d9
fix(api): mask job archive passwords in the native queue response
xbmc4lyfe Oct 9, 2026
7c1e455
fix(config): honour general.log_level from config.toml
xbmc4lyfe Oct 9, 2026
cf43930
fix(postproc): pass archive passwords to unrar/7z on stdin, not argv
xbmc4lyfe Oct 9, 2026
df706bb
fix(sab): report the job's post-processing level as queue unpackopts
xbmc4lyfe Oct 9, 2026
9b71604
fix(api): read category/priority/name from /api/queue/add form fields
xbmc4lyfe Oct 9, 2026
3215f6e
fix(postproc): prefer 7zz over 7z when picking the 7-Zip binary
xbmc4lyfe Oct 9, 2026
37141cf
fix(sab): accept a target nzo_id as mode=switch value2
xbmc4lyfe Oct 9, 2026
5a3d731
fix(import): fall back to [[section]] header for SABnzbd INI names
xbmc4lyfe Oct 9, 2026
e05446d
fix(core): strip bidi controls and zero-width spaces from job and fil…
xbmc4lyfe Oct 9, 2026
61dbb31
fix(sab): implement mode=retry_all
xbmc4lyfe Oct 9, 2026
f22c510
fix(sab): keep the archives for pp=2 (repair+unpack without delete)
xbmc4lyfe Oct 9, 2026
4bc45d7
fix(api): return 400 url_rejected for fetch-guard rejections
xbmc4lyfe Oct 9, 2026
fa62ce8
fixup! fix(sab): keep the archives for pp=2 (repair+unpack without de…
thedancingdeveloper Oct 9, 2026
4ae7ce8
Revert "fixup! fix(sab): keep the archives for pp=2 (repair+unpack wi…
thedancingdeveloper Oct 9, 2026
0e94189
test: set delete_archives on NzbJob and HistoryEntry fixtures
thedancingdeveloper Oct 9, 2026
4f57a74
fix(api): keep multipart read failures as client errors on queue add
thedancingdeveloper Oct 9, 2026
be60f86
test(e2e): expect the fetch-guard reason for an unreachable loopback URL
thedancingdeveloper Oct 9, 2026
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
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ feed-rs = "2"
unicode-normalization = "0.1"

# Shared NZB crates
nzb-web = { version = "0.4.23", path = "crates/nzb-web", features = ["groups-db"] }
nzb-web = { version = "0.4.24", path = "crates/nzb-web", features = ["groups-db"] }
nzb-nntp = { version = "0.2.24", path = "crates/nzb-nntp" }
nzb-core = { version = "0.2.18", path = "crates/nzb-core", features = ["groups-db"] }
nzb-core = { version = "0.2.19", path = "crates/nzb-core", features = ["groups-db"] }
nzb-decode = { version = "0.1.5", path = "crates/nzb-decode" }
nzb-news = { version = "0.1.14", path = "crates/nzb-news" }
nzb-dispatch = { version = "0.2.8", path = "crates/nzb-dispatch" }
Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,26 @@ curl -X POST http://localhost:9090/api/queue/add-url \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/file.nzb", "category": "movies"}'

# Upload NZB file
# Upload NZB file (optional fields: category/cat, priority 0-3,
# name/nzbname; each may also be passed as a query parameter, and a form
# field wins over the query)
curl -X POST http://localhost:9090/api/queue/add \
-F "file=@/path/to/file.nzb" -F "category=tv"
-F "file=@/path/to/file.nzb" -F "category=tv" -F "priority=2"

# Upload an NZB with an archive password (multipart `password` field;
# `add-url` takes a JSON `password`)
curl -X POST http://localhost:9090/api/queue/add \
-F "file=@/path/to/file.nzb" -F "password=secret"

# Check status
curl http://localhost:9090/api/status
```

Job names follow SABnzbd's inline-password convention on both APIs: a name
or NZB file name of the form `Name{{password}}` or `Name/password` sets the
job password and is queued as `Name`. An explicit `password` wins over the
inline one, which wins over a password embedded in the NZB.

The *arr-compatible API is served at `/sabnzbd/api`.

---
Expand Down
19 changes: 16 additions & 3 deletions apps/rustnzb/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,33 @@ incomplete_dir = "/downloads/incomplete"
complete_dir = "/downloads/complete"
data_dir = "/data"
speed_limit_bps = 0 # 0 = unlimited
cache_size = 524288000 # 500 MB
cache_size = 524288000 # 500 MB; the API accepts 16 MiB to 64 GiB
# Log level: trace, debug, info, warn, error, or off. A full filter such as
# "info,nzb_nntp=debug" is also accepted. Precedence, highest first:
# RUST_LOG env (full filter override) > --log-level CLI flag >
# RUSTNZB_LOG_LEVEL env > this setting > "info".
# An invalid value logs a warning and falls back to "info". Applied at start.
log_level = "info"
# max_active_downloads = 1 # 0 = unlimited; the API accepts at most 100
# log_file = "data/rustnzb.log"
# history_retention = 100 # Number of NZBs to keep in history (omit or 0 = keep all)
max_active_downloads = 1 # NZBs downloading simultaneously (0 = unlimited)
# auto_sort_remaining_pct = false # Keep queued items ordered by remaining work
# rss_downloaded_item_expiry_days = 30 # Remove downloaded RSS records after this many days
# Independent post-processing jobs overlap downloads. Separate repair and
# extraction gates allow those stages to overlap safely across different jobs.
# These limits are applied at process start.
# These limits are applied at process start. The API accepts 0..=64 per
# limit (0 is raised to 1).
max_post_processing_jobs = 2
max_repair_workers = 1
max_extract_workers = 1
direct_unpack = true # Extract RAR volumes while downloading when unrar is available
max_nested_archive_depth = 5 # 0=outer archive only; protects against unbounded nesting
max_nested_archive_depth = 5 # 0=outer archive only; protects against unbounded nesting (API: 0-10)
# The download-health settings below are applied at process start.
# abort_hopeless = true # edited via /api/config/disk-guards
# early_failure_check = true # abort dead NZBs after the first articles
# required_completion_pct = 100.2 # 100.0-200.0; >100 reserves a PAR2 repair margin
# article_timeout_secs = 30 # 0 = no timeout; the API accepts at most 3600
# Post-processing hooks run without a shell, with a bounded timeout and output capture.
# scripts_dir = "/data/scripts"
# script_success = "on-success.sh"
Expand Down
9 changes: 7 additions & 2 deletions apps/rustnzb/frontend/src/app/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function makeApp(postResult = new Subject<unknown>()) {
navigate: vi.fn(() => Promise.resolve(true)),
};
const pauseState = new PauseStateService();
const snack = { open: vi.fn() };
const app = new App(
api as never,
auth as never,
Expand All @@ -31,8 +32,9 @@ function makeApp(postResult = new Subject<unknown>()) {
{} as never,
{} as never,
pauseState,
snack as never,
);
return { app, api, pauseState, postResult };
return { app, api, pauseState, postResult, snack };
}

describe('App global pause control', () => {
Expand All @@ -46,13 +48,16 @@ describe('App global pause control', () => {
});

it('rolls back the shared state if the global request fails', () => {
const { app, pauseState, postResult } = makeApp();
const { app, pauseState, postResult, snack } = makeApp();
vi.spyOn(app, 'pollStatus').mockImplementation(() => {});

app.togglePause();
postResult.error(new Error('request failed'));

expect(pauseState.paused()).toBe(false);
expect(snack.open).toHaveBeenCalledWith('Failed to pause downloads', 'Close', {
duration: 5000,
});
});
});

Expand Down
15 changes: 13 additions & 2 deletions apps/rustnzb/frontend/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { WidthModeService } from './core/services/width-mode.service';
import { PauseStateService } from './core/services/pause-state.service';
import { ThemeService } from './core/services/theme.service';
import { IconComponent } from './shared/icon.component';
import { MatSnackBar } from '@angular/material/snack-bar';
import { showHttpError } from './core/http/http-error';

export function isDemoPath(pathname: string): boolean {
return pathname === '/demo' || pathname.startsWith('/demo/');
Expand Down Expand Up @@ -451,6 +453,7 @@ export class App implements OnInit, OnDestroy {
public widthMode: WidthModeService,
readonly theme: ThemeService,
pauseState: PauseStateService,
private snack: MatSnackBar,
) {
this.paused = pauseState.paused;
this.authenticated = authService.authenticated;
Expand Down Expand Up @@ -509,9 +512,14 @@ export class App implements OnInit, OnDestroy {
this.paused.set(!wasPaused);
this.api.post(action).subscribe({
next: () => this.pollStatus(),
error: () => {
error: (err) => {
this.paused.set(wasPaused);
this.pollStatus();
showHttpError(
this.snack,
err,
wasPaused ? 'Failed to resume downloads' : 'Failed to pause downloads',
);
},
});
this.pauseMenuOpen = false;
Expand All @@ -524,7 +532,10 @@ export class App implements OnInit, OnDestroy {
}

pauseFor(secs: number): void {
this.api.post(`/queue/pause-for?duration_secs=${secs}`).subscribe(() => this.pollStatus());
this.api.post(`/queue/pause-for?duration_secs=${secs}`).subscribe({
next: () => this.pollStatus(),
error: (err) => showHttpError(this.snack, err, 'Failed to pause downloads'),
});
this.pauseMenuOpen = false;
}

Expand Down
82 changes: 82 additions & 0 deletions apps/rustnzb/frontend/src/app/core/http/http-error.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import '@angular/compiler';

import { HttpErrorResponse } from '@angular/common/http';
import { describe, expect, it, vi } from 'vitest';

import { httpErrorDetail, httpErrorMessage, isAuthHandledError, showHttpError } from './http-error';

function httpError(status: number, error: unknown): HttpErrorResponse {
return new HttpErrorResponse({ status, error, url: '/api/x' });
}

describe('httpErrorMessage', () => {
it('prefers the central human_readable field', () => {
const err = httpError(400, {
error_kind: 'bad_request',
human_readable: 'Feed URL rejected: URL targets a private/reserved address',
status: 400,
});
expect(httpErrorMessage(err, 'Failed to save feed')).toBe(
'Failed to save feed: Feed URL rejected: URL targets a private/reserved address',
);
});

it('falls back to the legacy error and message fields', () => {
expect(httpErrorMessage(httpError(400, { status: false, error: 'No job ID' }), 'Failed')).toBe(
'Failed: No job ID',
);
expect(httpErrorMessage(httpError(422, { message: 'Bad NZB' }), 'Failed')).toBe(
'Failed: Bad NZB',
);
});

it('parses JSON bodies delivered as text and accepts short plain-text bodies', () => {
expect(
httpErrorMessage(httpError(400, '{"human_readable":"Invalid regex"}'), 'Failed to save rule'),
).toBe('Failed to save rule: Invalid regex');
expect(httpErrorMessage(httpError(415, 'Expected JSON content type'), 'Failed')).toBe(
'Failed: Expected JSON content type',
);
});

it('uses the fallback when there is no usable server message', () => {
expect(httpErrorMessage(httpError(500, null), 'Failed to save')).toBe('Failed to save');
expect(
httpErrorMessage(httpError(502, '<html><body>Bad gateway</body></html>'), 'Failed'),
).toBe('Failed');
expect(httpErrorMessage(httpError(400, { human_readable: ' ' }), 'Failed')).toBe('Failed');
expect(httpErrorMessage(new Error('boom'), 'Failed')).toBe('Failed');
expect(httpErrorMessage(undefined, 'Failed')).toBe('Failed');
});

it('does not repeat the fallback when the server message already is it', () => {
expect(httpErrorMessage(httpError(400, { error: 'Failed' }), 'Failed')).toBe('Failed');
});
});

describe('httpErrorDetail', () => {
it('returns only the server text, or null', () => {
expect(httpErrorDetail(httpError(400, { human_readable: 'Bad', error: 'ignored' }))).toBe(
'Bad',
);
expect(httpErrorDetail(httpError(0, null))).toBeNull();
expect(httpErrorDetail(new Error('client'))).toBeNull();
});
});

describe('showHttpError', () => {
it('opens a snackbar with the extracted message', () => {
const snack = { open: vi.fn() };
showHttpError(snack, httpError(400, { human_readable: 'Bad' }), 'Failed to save');
expect(snack.open).toHaveBeenCalledWith('Failed to save: Bad', 'Close', { duration: 5000 });
});

it('stays silent on 401s, which the auth interceptor already handles', () => {
const snack = { open: vi.fn() };
const err = httpError(401, { human_readable: 'Unauthorized' });
expect(isAuthHandledError(err)).toBe(true);
showHttpError(snack, err, 'Failed to save');
expect(snack.open).not.toHaveBeenCalled();
expect(isAuthHandledError(httpError(403, null))).toBe(false);
});
});
80 changes: 80 additions & 0 deletions apps/rustnzb/frontend/src/app/core/http/http-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { HttpErrorResponse } from '@angular/common/http';

/** Anything with MatSnackBar's `open` signature (kept narrow for tests). */
export interface SnackOpener {
open(message: string, action?: string, config?: { duration?: number }): unknown;
}

const MAX_TEXT_BODY = 300;

function nonEmpty(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}

function fromBody(body: unknown): string | null {
if (typeof body === 'string') {
const text = body.trim();
if (!text) return null;
if (text.startsWith('{')) {
try {
return fromBody(JSON.parse(text));
} catch {
// Not JSON after all; treat it as plain text below.
}
}
// Short plain-text bodies (e.g. axum extractor rejections) are useful;
// HTML error pages from a proxy are not.
if (text.startsWith('<') || text.length > MAX_TEXT_BODY) return null;
return text;
}
if (body && typeof body === 'object') {
const b = body as Record<string, unknown>;
// Central API error shape first, then the legacy `{status:false, error}`
// shape, then older `{message}` bodies.
return nonEmpty(b['human_readable']) ?? nonEmpty(b['error']) ?? nonEmpty(b['message']);
}
return null;
}

/**
* The server's own explanation of a failed request, or `null` when the
* response carries nothing usable (network error, HTML proxy page, ...).
*/
export function httpErrorDetail(err: unknown): string | null {
// Duck-typed rather than `instanceof HttpErrorResponse` so errors that were
// re-thrown or built by hand with the same `{status, error}` shape work too.
if (!err || typeof err !== 'object' || !('error' in err)) return null;
return fromBody((err as HttpErrorResponse).error);
}

/**
* Turn a failed request into a user-facing message: the server's own
* explanation (`human_readable`, falling back to `error`/`message`) prefixed
* with what the user was trying to do, or just `fallback` when the response
* carries nothing usable.
*/
export function httpErrorMessage(err: unknown, fallback: string): string {
const detail = httpErrorDetail(err);
if (!detail || detail === fallback) return fallback;
return `${fallback}: ${detail}`;
}

/**
* A 401 reaching a component means the auth interceptor already tried to
* refresh the session and has redirected to the login page; a toast on top
* of that redirect is noise.
*/
export function isAuthHandledError(err: unknown): boolean {
return !!err && typeof err === 'object' && (err as HttpErrorResponse).status === 401;
}

/** Show a failed request as a toast, unless the auth flow is handling it. */
export function showHttpError(
snack: SnackOpener,
err: unknown,
fallback: string,
duration = 5000,
): void {
if (isAuthHandledError(err)) return;
snack.open(httpErrorMessage(err, fallback), 'Close', { duration });
}
6 changes: 6 additions & 0 deletions apps/rustnzb/frontend/src/app/core/models/queue.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export interface ServerArticleStats {
bytes_downloaded: number;
}

/** Why a job or the whole queue is paused; `disk_space` lifts automatically. */
export type PauseReason = 'manual' | 'global' | 'disk_space' | 'server';

export interface NzbJob {
id: string;
name: string;
Expand All @@ -24,13 +27,15 @@ export interface NzbJob {
speed_bps: number;
error_message: string | null;
server_stats: ServerArticleStats[];
pause_reason?: PauseReason | null;
}

export interface QueueResponse {
jobs: NzbJob[];
total: number;
speed_bps: number;
paused: boolean;
pause_reason?: PauseReason | null;
}

export interface StatusResponse {
Expand All @@ -43,6 +48,7 @@ export interface StatusResponse {
disk_space_total: number;
min_free_space_bytes: number;
paused: boolean;
pause_reason?: PauseReason | null;
pause_remaining_secs: number | null;
webdav_available: boolean;
webdav_enabled: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
import { httpErrorDetail } from '../../core/http/http-error';

@Component({
selector: 'app-login',
Expand Down Expand Up @@ -210,10 +211,8 @@ export class LoginComponent implements OnInit {
this.errorMessage.set('Invalid username or password.');
} else if (err.status === 409) {
this.errorMessage.set('An account already exists. Please sign in instead.');
} else if (err.error?.message) {
this.errorMessage.set(err.error.message);
} else {
this.errorMessage.set('An error occurred. Please try again.');
this.errorMessage.set(httpErrorDetail(err) ?? 'An error occurred. Please try again.');
}
},
});
Expand Down
Loading
Loading