Skip to content
Closed
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
38 changes: 38 additions & 0 deletions apps/rustnzb/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,44 @@ script_max_output_bytes = 1048576
# pipelining = 4
# optional = true

# Time-of-day download schedule. Rules are evaluated against the process's
# local wall clock; set TZ in the container environment to control it.
# Windows may cross midnight: a window belongs to the day it starts on, and
# end < start means it runs into the next morning. `24:00` ends the day.
# Actions: pause | speed_limit | disable_servers.
#
# The schedule is an overlay. It never rewrites the configured speed limit or
# server list: when a window ends, `speed_limit_bps` and every server's
# `enabled` flag come back as configured. It only lifts a pause it applied,
# so a manual pause or a disk-space hold survives.
#
# [schedule]
# enabled = false
#
# [[schedule.rules]]
# id = "quiet-hours" # omitted in the UI; the API assigns one
# name = "Quiet hours"
# days = ["mon", "tue", "wed", "thu", "fri"]
# start = "01:00"
# end = "06:00"
# action = "pause"
#
# [[schedule.rules]]
# name = "Saturday peak cap"
# days = ["sat", "sun"]
# start = "00:00"
# end = "24:00"
# action = "speed_limit"
# speed_limit_bps = 2000000 # 0 = unlimited, and overrides other active caps
#
# [[schedule.rules]]
# name = "Backup server maintenance"
# days = ["sun"]
# start = "03:00"
# end = "05:00"
# action = "disable_servers"
# servers = ["backup"] # ids from [[servers]]

# Download categories
[[categories]]
name = "Default"
Expand Down
4 changes: 2 additions & 2 deletions apps/rustnzb/frontend/src/app/core/models/queue.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ 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';
/** Why a job or the whole queue is paused; `disk_space` and `schedule` lift automatically. */
export type PauseReason = 'manual' | 'global' | 'disk_space' | 'server' | 'schedule';

export interface NzbJob {
id: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,27 @@ describe('QueueViewComponent', () => {
expect(component.statusLabel(makeJob({ status: 'queued' }))).toBe('paused · low disk');
});

it('labels a job paused by the schedule and explains the automatic hold', () => {
const { component } = makeComponent({
get: vi.fn((url: string) =>
url === '/queue'
? of({ jobs: [], total: 0, speed_bps: 0, paused: true, pause_reason: 'schedule' })
: of({}),
),
});

component.loadQueue();

expect(component.pausedLabel()).toBe('Paused · schedule');
const held = makeJob({ status: 'paused', pause_reason: 'schedule' });
expect(component.statusLabel(held)).toBe('paused · schedule');
expect(component.pauseHoldTitle(held)).toContain('resumes automatically');
expect(component.isDiskSpaceHold(held)).toBe(false);
expect(
component.pauseHoldTitle(makeJob({ status: 'paused', pause_reason: 'manual' })),
).toBeNull();
});

it('keeps the plain paused label for a user pause', () => {
const { component } = makeComponent({
get: vi.fn((url: string) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,11 +446,7 @@ interface PipelineStep {
<span
class="status-pill"
[class]="statusClass(effectiveStatus(job.status))"
[attr.title]="
isDiskSpaceHold(job)
? 'Waiting for free disk space; resumes automatically'
: null
"
[attr.title]="pauseHoldTitle(job)"
>{{ statusLabel(job) }}</span
>
</td>
Expand Down Expand Up @@ -1328,9 +1324,16 @@ export class QueueViewComponent implements OnInit, OnDestroy {

speedValue = computed(() => formatBytesParts(this.status()?.speed_bps).value);
speedUnit = computed(() => `${formatBytesParts(this.status()?.speed_bps).unit}/s`);
pausedLabel = computed(() =>
this.pauseReason() === 'disk_space' ? 'Paused · low disk space' : 'Paused',
);
pausedLabel = computed(() => {
switch (this.pauseReason()) {
case 'disk_space':
return 'Paused · low disk space';
case 'schedule':
return 'Paused · schedule';
default:
return 'Paused';
}
});
/** Sub-line of the speed card: pause state, else the configured limit (0 = off). */
speedLimitLabel = computed(() => {
if (this.paused()) return this.pausedLabel();
Expand Down Expand Up @@ -1918,16 +1921,38 @@ export class QueueViewComponent implements OnInit, OnDestroy {
return 's-q';
}

/** Why a paused job is holding, when the hold lifts without the user. */
private automaticPauseReason(job: NzbJob): PauseReason | null {
if (this.effectiveStatus(job.status) !== 'paused') return null;
const reason = job.pause_reason ?? (this.paused() ? this.pauseReason() : null);
return reason === 'disk_space' || reason === 'schedule' ? reason : null;
}

/** Whether this job is paused by the disk-space guard. */
isDiskSpaceHold(job: NzbJob): boolean {
if (this.effectiveStatus(job.status) !== 'paused') return false;
const reason = job.pause_reason ?? (this.paused() ? this.pauseReason() : null);
return reason === 'disk_space';
return this.automaticPauseReason(job) === 'disk_space';
}

pauseHoldTitle(job: NzbJob): string | null {
switch (this.automaticPauseReason(job)) {
case 'disk_space':
return 'Waiting for free disk space; resumes automatically';
case 'schedule':
return 'Paused by the download schedule; resumes automatically';
default:
return null;
}
}

statusLabel(job: NzbJob): string {
if (this.isDiskSpaceHold(job)) return 'paused · low disk';
return this.displayStatus(this.effectiveStatus(job.status));
switch (this.automaticPauseReason(job)) {
case 'disk_space':
return 'paused · low disk';
case 'schedule':
return 'paused · schedule';
default:
return this.displayStatus(this.effectiveStatus(job.status));
}
}

effectiveStatus(status: string): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ function makeComponent(overrides: Record<string, ReturnType<typeof vi.fn>> = {})
'/config/max-active-downloads': { max_active_downloads: 4 },
'/config/history-retention': { retention: 14 },
'/config/disk-guards': { min_free_space_bytes: 2 * 1024 ** 3, abort_hopeless: false },
'/config/schedule': {
enabled: false,
rules: [],
active_rule_ids: [],
paused: false,
speed_limit_bps: null,
disabled_servers: [],
},
'/config/debug-logging': { enabled: false, effective: 'info', overridden: false },
'/config/sabnzbd-api-key': { api_key: 'key' },
'/status': { webdav_available: false, webdav_enabled: false },
Expand Down Expand Up @@ -114,12 +122,14 @@ describe('SettingsViewComponent', () => {
component.saveCategory();
component.saveServer();
component.saveDavConfig();
component.saveSchedule();
component.rotateSabApiKey();

expect(api.put).not.toHaveBeenCalled();
expect(api.post).not.toHaveBeenCalled();
expect(confirm.confirm).not.toHaveBeenCalled();
expect(component.canSave('speedLimit')).toBe(false);
expect(component.canSave('schedule')).toBe(false);
expect(component.loadFailure('speedLimit')).toBeNull();
});

Expand Down Expand Up @@ -181,6 +191,152 @@ describe('SettingsViewComponent', () => {
expect(api.put).not.toHaveBeenCalledWith('/config/dav', expect.anything());
});

it('loads the schedule and shows which rules are active now', () => {
const { component, api } = makeComponent({
get: vi.fn((path: string) => {
if (path === '/config/schedule') {
return of({
enabled: true,
rules: [
{
id: 'quiet',
name: 'Quiet hours',
days: ['mon', 'monday', 'tue'],
start: '22:00',
end: '06:00',
action: 'pause',
speed_limit_bps: 0,
servers: [],
},
],
active_rule_ids: ['quiet'],
paused: true,
speed_limit_bps: null,
disabled_servers: [],
});
}
if (path === '/config/servers') return of([]);
if (path === '/config/categories' || path === '/config/servers/stats') return of([]);
if (path === '/status') return of({ webdav_available: false, webdav_enabled: false });
if (path === '/config') return of({ general: {} });
return of({});
}),
});
component.ngOnInit();

expect(api.get).toHaveBeenCalledWith('/config/schedule');
expect(component.schedule.rules[0].days).toEqual(['mon', 'tue']);
expect(component.scheduleActiveNow()).toBe(true);
expect(component.activeScheduleRules().map((rule) => rule.name)).toEqual(['Quiet hours']);
expect(component.actionLabel('pause')).toBe('pause');
expect(component.unsavedSections()).toEqual([]);
});

it('validates a draft schedule rule before saving and names the offending row', () => {
const { component, api, snack } = makeComponent();
component.ngOnInit();

component.addScheduleRule();
expect(component.canSave('schedule')).toBe(true);
expect(component.schedule.rules[0].days).toEqual(['mon', 'tue', 'wed', 'thu', 'fri']);
expect(component.scheduleProblem()).toBeNull();

component.schedule.rules[0].start = '25:00';
expect(component.ruleProblem(component.schedule.rules[0])).toContain('Start must be HH:MM');
expect(component.scheduleProblem()).toBe('Rule 1: Start must be HH:MM (00:00 to 23:59).');
component.saveSchedule();
expect(api.put).not.toHaveBeenCalledWith('/config/schedule', expect.anything());
expect(snack.error).toHaveBeenCalledWith('Rule 1: Start must be HH:MM (00:00 to 23:59).', 4000);

component.schedule.rules[0].start = '22:00';
component.schedule.rules[0].end = '22:00';
expect(component.ruleProblem(component.schedule.rules[0])).toContain('must differ');

component.schedule.rules[0].action = 'disable_servers';
component.schedule.rules[0].end = '06:00';
expect(component.ruleProblem(component.schedule.rules[0])).toContain('at least one server');
component.toggleScheduleServer(component.schedule.rules[0], 'primary', {
target: { checked: true },
} as unknown as Event);
expect(component.scheduleProblem()).toBeNull();
});

it('saves the schedule and takes the ids and live status from the response', () => {
const put = vi.fn(() =>
of({
enabled: true,
rules: [
{
id: 'assigned',
name: 'Off peak',
days: ['sat', 'sun'],
start: '00:00',
end: '24:00',
action: 'speed_limit',
speed_limit_bps: 1_000_000,
servers: [],
},
],
active_rule_ids: ['assigned'],
paused: false,
speed_limit_bps: 1_000_000,
disabled_servers: [],
}),
);
const { component, api, snack } = makeComponent({ put });
component.ngOnInit();

component.schedule.enabled = true;
component.addScheduleRule();
component.schedule.rules[0].name = ' Off peak ';
component.schedule.rules[0].days = ['sat', 'sun'];
component.schedule.rules[0].start = '00:00';
component.schedule.rules[0].end = '24:00';
component.schedule.rules[0].action = 'speed_limit';
component.schedule.rules[0].speed_limit_bps = 1_000_000;
component.saveSchedule();

expect(api.put).toHaveBeenCalledWith('/config/schedule', {
enabled: true,
rules: [
{
id: '',
name: 'Off peak',
days: ['sat', 'sun'],
start: '00:00',
end: '24:00',
action: 'speed_limit',
speed_limit_bps: 1_000_000,
servers: [],
},
],
});
expect(component.schedule.rules[0].id).toBe('assigned');
expect(component.scheduleActiveNow()).toBe(true);
expect(component.formatSpeedValue(component.schedule.speed_limit_bps)).toContain('/s');
expect(snack.success).toHaveBeenCalledWith('Schedule saved');
expect(component.hasUnsavedChanges()).toBe(false);
});

it('keeps schedule edits dirty when the save fails and blocks saving after a failed load', () => {
const { component, api } = makeComponent({
put: vi.fn(() => throwError(() => new Error('offline'))),
});
component.ngOnInit();
component.addScheduleRule();
component.schedule.rules[0].start = '23:00';
expect(component.unsavedSections()).toEqual(['schedule']);
component.saveSchedule();
expect(component.unsavedSections()).toEqual(['schedule']);

const { component: blocked, api: blockedApi } = makeComponent();
failGet(blockedApi, '/config/schedule');
blocked.ngOnInit();
expect(blocked.loadFailure('schedule')).toBe('Failed to load the schedule');
blocked.saveSchedule();
expect(blockedApi.put).not.toHaveBeenCalledWith('/config/schedule', expect.anything());
});

it('surfaces a failed status load as a toast', () => {
const { component, api, snack } = makeComponent();
failGet(api, '/status');
Expand Down
Loading
Loading