diff --git a/apps/rustnzb/config.example.toml b/apps/rustnzb/config.example.toml index 63e19df5..366576fa 100644 --- a/apps/rustnzb/config.example.toml +++ b/apps/rustnzb/config.example.toml @@ -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" diff --git a/apps/rustnzb/frontend/src/app/core/models/queue.model.ts b/apps/rustnzb/frontend/src/app/core/models/queue.model.ts index e1f79e56..9a89bc11 100644 --- a/apps/rustnzb/frontend/src/app/core/models/queue.model.ts +++ b/apps/rustnzb/frontend/src/app/core/models/queue.model.ts @@ -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; diff --git a/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.spec.ts b/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.spec.ts index 4e892108..b0a3265a 100644 --- a/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.spec.ts +++ b/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.spec.ts @@ -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) => diff --git a/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.ts b/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.ts index ca291db7..e2c45e35 100644 --- a/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.ts +++ b/apps/rustnzb/frontend/src/app/features/queue/queue-view.component.ts @@ -446,11 +446,7 @@ interface PipelineStep { {{ statusLabel(job) }} @@ -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(); @@ -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 { diff --git a/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.spec.ts b/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.spec.ts index f6239749..32381ffb 100644 --- a/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.spec.ts +++ b/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.spec.ts @@ -17,6 +17,14 @@ function makeComponent(overrides: Record> = {}) '/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 }, @@ -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(); }); @@ -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'); diff --git a/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.ts b/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.ts index 61de7874..18b09af8 100644 --- a/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.ts +++ b/apps/rustnzb/frontend/src/app/features/settings/settings-view.component.ts @@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { ApiService } from '../../core/services/api.service'; -import { formatBytes } from '../../core/format'; +import { formatBytes, formatSpeed } from '../../core/format'; import { StatusResponse } from '../../core/models/queue.model'; import { ConfirmService } from '../../shared/confirm.service'; import { IconComponent } from '../../shared/icon.component'; @@ -65,12 +65,65 @@ type Tab = | 'categories' | 'postproc' | 'paths' + | 'schedule' | 'dav' | 'general' | 'api' | 'telemetry' | 'about'; +type ScheduleAction = 'pause' | 'speed_limit' | 'disable_servers'; + +/** `MM:SS`-free 24-hour clock, with `24:00` allowed as the end of the day. */ +const CLOCK_RE = /^([01]\d|2[0-3]):[0-5]\d$|^24:00$/; + +const SHORT_DAYS: Record = { + mon: 'mon', + monday: 'mon', + tue: 'tue', + tues: 'tue', + tuesday: 'tue', + wed: 'wed', + weds: 'wed', + wednesday: 'wed', + thu: 'thu', + thur: 'thu', + thurs: 'thu', + thursday: 'thu', + fri: 'fri', + friday: 'fri', + sat: 'sat', + saturday: 'sat', + sun: 'sun', + sunday: 'sun', +}; + +function shortDay(day: string): string { + return SHORT_DAYS[day.trim().toLowerCase()] ?? ''; +} + +/** One time-of-day window, as `GET/PUT /api/config/schedule` exchanges it. */ +interface ScheduleRule { + id: string; + name: string; + days: string[]; + start: string; + end: string; + action: ScheduleAction; + speed_limit_bps: number; + servers: string[]; +} + +/** The saved schedule plus what it is doing right now. */ +interface ScheduleState { + enabled: boolean; + rules: ScheduleRule[]; + active_rule_ids: string[]; + paused: boolean; + speed_limit_bps: number | null; + disabled_servers: string[]; +} + interface DavConfig { enabled: boolean; auto_send_all: boolean; @@ -130,14 +183,15 @@ type SettingsKey = | 'maxActive' | 'historyRetention' | 'diskGuards' + | 'schedule' | 'debugLogging' | 'dav' | 'sabApiKey'; /** Settings edited in place and written by their own Save button. Toggles that save on change are not here. */ -type FormKey = 'speedLimit' | 'maxActive' | 'historyRetention' | 'diskGuards' | 'dav'; +type FormKey = 'speedLimit' | 'maxActive' | 'historyRetention' | 'diskGuards' | 'schedule' | 'dav'; -const FORM_KEYS: readonly FormKey[] = ['speedLimit', 'maxActive', 'historyRetention', 'diskGuards', 'dav']; +const FORM_KEYS: readonly FormKey[] = ['speedLimit', 'maxActive', 'historyRetention', 'diskGuards', 'schedule', 'dav']; function isFormKey(key: SettingsKey): key is FormKey { return (FORM_KEYS as readonly string[]).includes(key); @@ -153,6 +207,7 @@ function allLoading(): Record { maxActive: { status: 'loading' }, historyRetention: { status: 'loading' }, diskGuards: { status: 'loading' }, + schedule: { status: 'loading' }, debugLogging: { status: 'loading' }, dav: { status: 'loading' }, sabApiKey: { status: 'loading' }, @@ -183,6 +238,7 @@ function emptyCategory(): CategoryConfig { Post-processing + @if (webdavAvailable()) { } @@ -704,6 +760,189 @@ function emptyCategory(): CategoryConfig { } + + @if (tab === 'schedule') { + @if (loadFailure('schedule'); as message) { + + } +
+
+

Scheduler

+
+ Pause downloads, cap the speed, or take servers out of rotation on a + time-of-day window. Times use the server's local clock. +
+
+ +
+ +
+

+ Status + @if (scheduleActiveNow()) { + ● active now + } +

+
+
+ +
+ +
+ +
+ @if (!schedule.enabled) { + The schedule is off. + } @else if (!scheduleActiveNow()) { + No rule is active. + } @else { + @for (rule of activeScheduleRules(); track rule.id) { + {{ rule.name || 'Unnamed rule' }} · {{ actionLabel(rule.action) }} + } + @if (schedule.paused) { + paused by schedule + } + @if (schedule.speed_limit_bps !== null) { + cap {{ formatSpeedValue(schedule.speed_limit_bps) }} + } + @if (schedule.disabled_servers.length) { + {{ disabledServerNames() }} out of rotation + } + } +
+
+ The schedule only lifts a pause it applied. A manual pause, a disk-space hold, or + a paused job stays paused when a window ends. +
+
+
+
+ + @if (!schedule.rules.length) { +
+
No rules yet. Add one to give downloads a quiet hours window.
+
+ } + + @for (rule of schedule.rules; track rule.id; let i = $index) { +
+

+ {{ rule.name || 'New rule' }} + @if (isRuleActive(rule)) { + ● active now + } + + + +

+
+
+ +
+ +
+ + +
+ @for (day of weekdays; track day.value) { + + } +
+ + +
+ + to + + 24-hour · end earlier than start runs overnight +
+ + +
+ +
+ + @if (rule.action === 'speed_limit') { + +
+ + bytes/sec · 0 = unlimited (overrides other caps) +
+ } + + @if (rule.action === 'disable_servers') { + +
+ @for (server of servers(); track server.id) { + + } @empty { + No news servers configured. + } +
+ } + + @if (ruleProblem(rule); as problem) { + + } +
+
+
+ } + +
+ + +
+ } + @if (tab === 'dav') { @if (loadFailure('dav'); as message) { @@ -1449,6 +1688,55 @@ function emptyCategory(): CategoryConfig { word-break: break-all; } + /* Scheduler */ + .schedule-days, + .schedule-servers { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + align-items: center; + } + .schedule-day { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 13px; + color: var(--text); + background: var(--panel2); + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 4px 9px; + cursor: pointer; + } + .schedule-day:has(input:checked) { + border-color: var(--accent); + box-shadow: inset 0 0 0 1px var(--accent); + } + .schedule-day input { + margin: 0; + accent-color: var(--accent); + } + .schedule-now { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + } + .schedule-problem { + grid-column: 2; + color: var(--danger); + font-size: 12px; + } + .form .schedule-clock { + max-width: 90px; + font-variant-numeric: tabular-nums; + } + @media (max-width: 720px) { + .form { grid-template-columns: 1fr; } + .form .help, + .schedule-problem { grid-column: 1; } + } + .dir-table { display: flex; flex-direction: column; @@ -1589,6 +1877,25 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { minFreeSpaceGB = 1; abortHopeless = true; + // Scheduler + schedule: ScheduleState = { + enabled: false, + rules: [], + active_rule_ids: [], + paused: false, + speed_limit_bps: null, + disabled_servers: [], + }; + readonly weekdays = [ + { value: 'mon', label: 'Mon' }, + { value: 'tue', label: 'Tue' }, + { value: 'wed', label: 'Wed' }, + { value: 'thu', label: 'Thu' }, + { value: 'fri', label: 'Fri' }, + { value: 'sat', label: 'Sat' }, + { value: 'sun', label: 'Sun' }, + ]; + // Status / feature flags status = signal(null); webdavAvailable = computed(() => this.status()?.webdav_available ?? false); @@ -1630,6 +1937,7 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { this.loadServers(); this.loadCategories(); this.loadGeneralSettings(); + this.loadSchedule(); this.loadSabApiKey(); this.loadStatus(); this.loadDirs(); @@ -1654,6 +1962,8 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { return JSON.stringify(this.historyRetention); case 'diskGuards': return JSON.stringify([this.minFreeSpaceGB, this.abortHopeless]); + case 'schedule': + return JSON.stringify({ enabled: this.schedule.enabled, rules: this.schedule.rules }); case 'dav': return JSON.stringify(this.davConfig); } @@ -2124,6 +2434,155 @@ export class SettingsViewComponent implements OnInit, HasUnsavedChanges { ); } + // ======================== SCHEDULER ======================== + + loadSchedule(): void { + this.loadSetting('schedule', '/config/schedule', 'Failed to load the schedule', (state) => { + const rules = Array.isArray(state.rules) ? state.rules : []; + this.schedule = { + ...this.schedule, + ...state, + rules: rules.map((rule) => ({ + ...rule, + // A hand-edited config may spell days out in full; the checkboxes + // work in the short form the API writes. + days: [...new Set((rule.days ?? []).map((day) => shortDay(day)))].filter(Boolean), + })), + }; + }); + } + + /** Re-read the schedule, dropping any unsaved edits. */ + reloadSchedule(): void { + this.loadSchedule(); + } + + scheduleActiveNow(): boolean { + return this.schedule.enabled && this.schedule.active_rule_ids.length > 0; + } + + activeScheduleRules(): ScheduleRule[] { + return this.schedule.rules.filter((rule) => this.isRuleActive(rule)); + } + + isRuleActive(rule: ScheduleRule): boolean { + return this.schedule.enabled && !!rule.id && this.schedule.active_rule_ids.includes(rule.id); + } + + disabledServerNames(): string { + const ids = this.schedule.disabled_servers; + const names = ids.map((id) => { + const server = this.servers().find((candidate) => candidate.id === id); + return server ? server.name || server.host : id; + }); + return names.join(', '); + } + + actionLabel(action: ScheduleAction): string { + switch (action) { + case 'pause': + return 'pause'; + case 'speed_limit': + return 'speed cap'; + case 'disable_servers': + return 'servers out'; + } + } + + formatSpeedValue(bps: number | null): string { + return bps === null ? 'unlimited' : formatSpeed(bps); + } + + addScheduleRule(): void { + if (!this.canSave('schedule')) return; + this.schedule.rules = [ + ...this.schedule.rules, + { + id: '', + name: '', + days: ['mon', 'tue', 'wed', 'thu', 'fri'], + start: '01:00', + end: '06:00', + action: 'pause', + speed_limit_bps: 0, + servers: [], + }, + ]; + } + + removeScheduleRule(index: number): void { + this.schedule.rules = this.schedule.rules.filter((_, i) => i !== index); + } + + toggleScheduleDay(rule: ScheduleRule, day: string, event: Event): void { + const checked = (event.target as HTMLInputElement).checked; + rule.days = checked + ? [...rule.days, day] + : rule.days.filter((candidate) => candidate !== day); + } + + toggleScheduleServer(rule: ScheduleRule, id: string, event: Event): void { + const checked = (event.target as HTMLInputElement).checked; + rule.servers = checked + ? [...rule.servers, id] + : rule.servers.filter((candidate) => candidate !== id); + } + + /** The first thing wrong with a draft rule, worded for the row. */ + ruleProblem(rule: ScheduleRule): string | null { + if (!rule.days.length) return 'Pick at least one day.'; + if (!CLOCK_RE.test(rule.start.trim())) return 'Start must be HH:MM (00:00 to 23:59).'; + if (!CLOCK_RE.test(rule.end.trim())) return 'End must be HH:MM (00:00 to 24:00).'; + if (rule.start.trim() === rule.end.trim()) return 'Start and end must differ.'; + if (rule.action === 'disable_servers' && !rule.servers.length) { + return 'Pick at least one server to take out of rotation.'; + } + return null; + } + + /** The first problem across every draft rule, or `null` when the form can be saved. */ + scheduleProblem(): string | null { + for (let i = 0; i < this.schedule.rules.length; i++) { + const problem = this.ruleProblem(this.schedule.rules[i]); + if (problem) return `Rule ${i + 1}: ${problem}`; + } + return null; + } + + saveSchedule(): void { + if (!this.canSave('schedule')) return; + const problem = this.scheduleProblem(); + if (problem) { + this.toast.error(problem, 4000); + return; + } + const sent = this.formSnapshot('schedule'); + const body = { + enabled: this.schedule.enabled, + rules: this.schedule.rules.map((rule) => ({ + ...rule, + name: rule.name.trim(), + days: [...rule.days], + servers: [...rule.servers], + })), + }; + this.api.put('/config/schedule', body).subscribe({ + next: (state) => { + // The response carries the ids the server assigned and what the + // schedule is doing now. If the form was edited while the save was + // in flight, keep that draft (dirty) instead of overwriting it; the + // server assigns ids on the next save. + if (this.formSnapshot('schedule') === sent) { + const rules = Array.isArray(state.rules) ? state.rules : []; + this.schedule = { ...state, rules: rules.map((rule) => ({ ...rule })) }; + } + this.baselines.schedule = this.formSnapshot('schedule'); + this.toast.success('Schedule saved'); + }, + error: (err) => showHttpError(this.toast, err, 'Failed to save the schedule'), + }); + } + // ======================== DAV CONFIG ======================== loadDavConfig(): void { diff --git a/apps/rustnzb/src/handlers.rs b/apps/rustnzb/src/handlers.rs index ad417056..a72d6d0d 100644 --- a/apps/rustnzb/src/handlers.rs +++ b/apps/rustnzb/src/handlers.rs @@ -20,6 +20,7 @@ static HTTP_CLIENT: std::sync::LazyLock = std::sync::LazyLock:: .expect("Failed to build shared HTTP client") }); +use chrono::Local; use nzb_web::nzb_core::NzbError; #[cfg(feature = "webdav")] use nzb_web::nzb_core::config::DavConfig; @@ -30,6 +31,7 @@ use nzb_web::nzb_core::config::{ use nzb_web::nzb_core::models::*; use nzb_web::nzb_core::nzb_parser; use nzb_web::nzb_core::sabnzbd_import; +use nzb_web::nzb_core::schedule::{ScheduleRule, evaluate_config}; use nzb_web::nzb_core::{HistoryFilter, HistorySummary}; use nzb_web::queue_manager::PauseReason; @@ -2075,6 +2077,127 @@ pub async fn h_set_speed_limit( Ok(Json(serde_json::json!({"status": true}))) } +// --------------------------------------------------------------------------- +// Download schedule +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub struct ScheduleResponse { + /// The saved schedule, with every rule's id filled in. + pub enabled: bool, + pub rules: Vec, + /// Rules active at the moment of the call. Empty while the schedule is off. + pub active_rule_ids: Vec, + /// True when a schedule rule is holding the queue paused right now. + pub paused: bool, + /// The cap a rule is applying right now, if any. The configured + /// `general.speed_limit_bps` applies when this is `null`. + pub speed_limit_bps: Option, + /// Servers a rule has out of rotation right now. + pub disabled_servers: Vec, +} + +/// GET /api/config/schedule -- The schedule and what it is doing now. +pub async fn h_schedule_get( + State(state): State>, +) -> Result, ApiError> { + let config = state.config(); + let decision = evaluate_config(&config.schedule, Local::now().naive_local()); + Ok(Json(ScheduleResponse { + enabled: config.schedule.enabled, + rules: config.schedule.rules.clone(), + active_rule_ids: decision.active_rule_ids, + paused: state.queue_manager.schedule_pause_active(), + speed_limit_bps: state.queue_manager.scheduled_speed_cap(), + disabled_servers: state.queue_manager.schedule_disabled_servers(), + })) +} + +#[derive(Deserialize)] +pub struct ScheduleUpdate { + pub enabled: bool, + pub rules: Vec, +} + +/// PUT /api/config/schedule -- Replace the schedule. +/// +/// Every rule is validated before anything is saved, so a bad rule never +/// half-applies. Rules without an id get one; ids stay stable across edits so +/// an active window survives a save. +pub async fn h_schedule_update( + State(state): State>, + body: Result, JsonRejection>, +) -> Result, ApiError> { + let Json(body) = + body.map_err(|rejection| ApiError::from((StatusCode::BAD_REQUEST, rejection.body_text())))?; + + let mut seen = std::collections::HashSet::new(); + for (i, rule) in body.rules.iter().enumerate() { + if let Some(problem) = nzb_web::nzb_core::schedule::rule_problem(rule) { + return Err(ApiError::from(( + StatusCode::BAD_REQUEST, + format!("invalid `schedule.rules[{i}]`: {problem}"), + ))); + } + if !rule.id.trim().is_empty() && !seen.insert(rule.id.clone()) { + return Err(ApiError::from(( + StatusCode::BAD_REQUEST, + format!("invalid `schedule.rules[{i}]`: duplicate id `{}`", rule.id), + ))); + } + } + let known_servers: std::collections::HashSet = state + .config() + .servers + .iter() + .map(|server| server.id.clone()) + .collect(); + for (i, rule) in body.rules.iter().enumerate() { + if let Some(unknown) = rule + .servers + .iter() + .find(|server| !known_servers.contains(*server)) + { + return Err(ApiError::from(( + StatusCode::BAD_REQUEST, + format!("invalid `schedule.rules[{i}].servers`: unknown server `{unknown}`"), + ))); + } + } + + let mut rules = body.rules; + for rule in &mut rules { + if rule.id.trim().is_empty() { + rule.id = uuid::Uuid::new_v4().to_string(); + } + rule.name = rule.name.trim().to_string(); + rule.days = rule + .days + .iter() + .map(|day| day.trim().to_ascii_lowercase()) + .collect(); + rule.start = rule.start.trim().to_string(); + rule.end = rule.end.trim().to_string(); + } + + state.update_config_with(|config| { + config.schedule.enabled = body.enabled; + config.schedule.rules = rules.clone(); + Ok::<_, ApiError>(()) + })?; + + // Applying now, rather than waiting for the monitor's next tick, makes a + // save feel immediate and keeps the response truthful. + let config = state.config(); + let decision = evaluate_config(&config.schedule, Local::now().naive_local()); + state + .queue_manager + .apply_schedule(&decision, config.schedule.enabled); + state.schedule_wake.notify_one(); + + h_schedule_get(State(state)).await +} + // --------------------------------------------------------------------------- // Disk guards handlers // --------------------------------------------------------------------------- diff --git a/apps/rustnzb/src/server.rs b/apps/rustnzb/src/server.rs index 94f59303..cd9300b3 100644 --- a/apps/rustnzb/src/server.rs +++ b/apps/rustnzb/src/server.rs @@ -345,6 +345,8 @@ fn build_api_router(state: Arc) -> Router { ) .route("/config/speed-limit", get(handlers::h_get_speed_limit)) .route("/config/speed-limit", put(handlers::h_set_speed_limit)) + .route("/config/schedule", get(handlers::h_schedule_get)) + .route("/config/schedule", put(handlers::h_schedule_update)) .route("/config/disk-guards", get(handlers::h_disk_guards_get)) .route("/config/disk-guards", put(handlers::h_disk_guards_set)) .route("/config/debug-logging", get(handlers::h_debug_logging_get)) diff --git a/apps/rustnzb/tests/schedule_api.rs b/apps/rustnzb/tests/schedule_api.rs new file mode 100644 index 00000000..d0918bf3 --- /dev/null +++ b/apps/rustnzb/tests/schedule_api.rs @@ -0,0 +1,349 @@ +//! The download schedule: validation, persistence, and how it applies to the +//! live queue. +//! +//! Windows are written to cover every day so the tests do not depend on when +//! they run; the evaluator's edge behaviour is tested in `nzb_core::schedule`. + +use std::sync::Arc; + +use arc_swap::ArcSwap; +use axum::extract::State; +use nzb_web::auth::{CredentialStore, TokenStore}; +use nzb_web::log_buffer::LogBuffer; +use nzb_web::nzb_core::config::{AppConfig, ServerConfig}; +use nzb_web::nzb_core::db::Database; +use nzb_web::nzb_core::schedule::{ScheduleAction, ScheduleRule}; +use nzb_web::queue_manager::PauseReason; +use nzb_web::state::AppState; +use rustnzb::handlers::{ + ScheduleUpdate, SetSpeedLimitBody, h_queue_pause_all, h_queue_resume_all, h_schedule_get, + h_schedule_update, h_set_speed_limit, +}; + +const ALL_DAYS: [&str; 7] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; + +fn test_state() -> (Arc, tempfile::TempDir) { + test_state_with_servers(Vec::new()) +} + +fn test_state_with_servers(servers: Vec) -> (Arc, tempfile::TempDir) { + let tempdir = tempfile::tempdir().expect("tempdir"); + let log_buffer = LogBuffer::default(); + let config = AppConfig { + servers, + ..Default::default() + }; + let manager = nzb_web::queue_manager::QueueManager::new( + config.servers.clone(), + Database::open_memory().expect("database"), + tempdir.path().join("incomplete"), + tempdir.path().join("complete"), + log_buffer.clone(), + 1, + Vec::new(), + 0, + config.general.speed_limit_bps, + false, + 5, + false, + false, + 100.0, + 30, + ); + let state = AppState::new( + Arc::new(ArcSwap::from_pointee(config)), + tempdir.path().join("config.toml"), + manager, + log_buffer, + Arc::new(TokenStore::new()), + Arc::new(CredentialStore::new(tempdir.path().to_path_buf())), + ); + (Arc::new(state), tempdir) +} + +fn rule(action: ScheduleAction) -> ScheduleRule { + ScheduleRule { + id: String::new(), + name: "Always".into(), + days: ALL_DAYS.iter().map(|day| (*day).to_string()).collect(), + start: "00:00".into(), + end: "24:00".into(), + action, + ..ScheduleRule::default() + } +} + +async fn update( + state: &Arc, + enabled: bool, + rules: Vec, +) -> serde_json::Value { + let response = h_schedule_update( + State(Arc::clone(state)), + Ok(axum::Json(ScheduleUpdate { enabled, rules })), + ) + .await + .expect("schedule update"); + serde_json::to_value(response.0).unwrap() +} + +async fn rejection(state: &Arc, rules: Vec) -> String { + match h_schedule_update( + State(Arc::clone(state)), + Ok(axum::Json(ScheduleUpdate { + enabled: true, + rules, + })), + ) + .await + { + Ok(_) => panic!("update unexpectedly succeeded"), + Err(error) => { + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + error.to_string() + } + } +} + +#[tokio::test] +async fn a_fresh_install_has_a_disabled_empty_schedule() { + let (state, _temp) = test_state(); + let body = h_schedule_get(State(state)).await.unwrap().0; + assert!(!body.enabled); + assert!(body.rules.is_empty()); + assert!(body.active_rule_ids.is_empty()); + assert!(!body.paused); + assert_eq!(body.speed_limit_bps, None); + assert!(body.disabled_servers.is_empty()); +} + +#[tokio::test] +async fn saving_assigns_ids_and_persists_the_rules() { + let (state, temp) = test_state(); + let body = update(&state, true, vec![rule(ScheduleAction::Pause)]).await; + + assert_eq!(body["enabled"], true); + let id = body["rules"][0]["id"].as_str().unwrap(); + assert!(!id.is_empty(), "an id is assigned when none is given"); + assert_eq!(body["rules"][0]["action"], "pause"); + + let saved = AppConfig::load(&temp.path().join("config.toml")).unwrap(); + assert!(saved.schedule.enabled); + assert_eq!(saved.schedule.rules.len(), 1); + assert_eq!(saved.schedule.rules[0].id, id); + assert_eq!(saved.schedule.rules[0].start, "00:00"); +} + +#[tokio::test] +async fn saving_keeps_an_existing_id_so_an_active_window_survives_an_edit() { + let (state, _temp) = test_state(); + let mut first = rule(ScheduleAction::Pause); + first.id = "night".into(); + let body = update(&state, true, vec![first]).await; + assert_eq!(body["rules"][0]["id"], "night"); +} + +#[tokio::test] +async fn invalid_rules_are_rejected_before_anything_is_saved() { + let (state, temp) = test_state(); + + let mut bad_day = rule(ScheduleAction::Pause); + bad_day.days = vec!["funday".into()]; + assert!(rejection(&state, vec![bad_day]).await.contains("funday")); + + let mut bad_clock = rule(ScheduleAction::Pause); + bad_clock.start = "9:00".into(); + assert!(rejection(&state, vec![bad_clock]).await.contains("HH:MM")); + + let mut empty_days = rule(ScheduleAction::Pause); + empty_days.days.clear(); + assert!( + rejection(&state, vec![empty_days]) + .await + .contains("at least one day") + ); + + let mut duplicate = rule(ScheduleAction::Pause); + duplicate.id = "same".into(); + let mut other = duplicate.clone(); + other.name = "Other".into(); + assert!( + rejection(&state, vec![duplicate, other]) + .await + .contains("duplicate id") + ); + + let mut unknown = rule(ScheduleAction::DisableServers); + unknown.servers = vec!["missing".into()]; + assert!( + rejection(&state, vec![unknown]) + .await + .contains("unknown server") + ); + + // Nothing was written. + assert!(!temp.path().join("config.toml").exists()); + assert!(state.config().schedule.rules.is_empty()); +} + +#[tokio::test] +async fn a_pause_rule_pauses_the_queue_and_reports_why() { + let (state, _temp) = test_state(); + update(&state, true, vec![rule(ScheduleAction::Pause)]).await; + + assert!(state.queue_manager.is_paused()); + assert!(state.queue_manager.schedule_pause_active()); + assert_eq!( + state.queue_manager.global_pause_reason(), + Some(PauseReason::Schedule) + ); + + let body = h_schedule_get(State(Arc::clone(&state))).await.unwrap().0; + assert!(body.paused); + assert_eq!(body.active_rule_ids.len(), 1); +} + +#[tokio::test] +async fn disabling_the_schedule_lifts_only_the_pause_it_owns() { + let (state, _temp) = test_state(); + update(&state, true, vec![rule(ScheduleAction::Pause)]).await; + + update(&state, false, vec![rule(ScheduleAction::Pause)]).await; + assert!(!state.queue_manager.is_paused()); + assert!(!state.queue_manager.schedule_pause_active()); + + // A user pause is not the schedule's to lift. + let _ = h_queue_pause_all(State(Arc::clone(&state))).await.unwrap(); + update(&state, true, vec![rule(ScheduleAction::Pause)]).await; + update(&state, false, vec![]).await; + assert!( + state.queue_manager.is_paused(), + "the schedule resumed a pause the user started" + ); + assert_eq!( + state.queue_manager.global_pause_reason(), + Some(PauseReason::Global) + ); +} + +#[tokio::test] +async fn a_speed_rule_caps_the_live_limit_without_rewriting_the_configured_one() { + let (state, temp) = test_state(); + let _ = h_set_speed_limit( + State(Arc::clone(&state)), + axum::Json(SetSpeedLimitBody { + speed_limit_bps: 2_000_000, + }), + ) + .await + .unwrap(); + + let mut cap = rule(ScheduleAction::SpeedLimit); + cap.speed_limit_bps = 500_000; + let body = update(&state, true, vec![cap]).await; + assert_eq!(body["speed_limit_bps"], 500_000); + assert_eq!(state.queue_manager.get_speed_limit(), 500_000); + + // The user's own value is untouched on disk and comes back when the cap ends. + assert_eq!(state.queue_manager.configured_speed_limit(), 2_000_000); + assert_eq!( + AppConfig::load(&temp.path().join("config.toml")) + .unwrap() + .general + .speed_limit_bps, + 2_000_000 + ); + + update(&state, false, vec![]).await; + assert_eq!(state.queue_manager.get_speed_limit(), 2_000_000); +} + +#[tokio::test] +async fn a_manual_resume_inside_a_window_is_not_undone_by_the_next_tick() { + let (state, _temp) = test_state(); + let rules = vec![rule(ScheduleAction::Pause)]; + update(&state, true, rules.clone()).await; + assert!(state.queue_manager.is_paused()); + + // The user resumes from the queue view. Saving the same schedule again is + // what the monitor does on its next minute tick. + let _ = h_queue_resume_all(State(Arc::clone(&state))).await.unwrap(); + let body = update(&state, true, rules).await; + + assert!( + !state.queue_manager.is_paused(), + "the schedule re-paused a queue the user resumed" + ); + assert!( + !body["paused"].as_bool().unwrap(), + "the API still reports a scheduled pause after the user resumed" + ); + // The rule is still active; the user simply overrode it for this window. + assert_eq!(body["active_rule_ids"].as_array().unwrap().len(), 1); +} + +#[tokio::test] +async fn an_unlimited_rule_lifts_the_configured_limit_for_its_window() { + let (state, _temp) = test_state(); + let _ = h_set_speed_limit( + State(Arc::clone(&state)), + axum::Json(SetSpeedLimitBody { + speed_limit_bps: 5_000_000, + }), + ) + .await + .unwrap(); + assert_eq!(state.queue_manager.get_speed_limit(), 5_000_000); + + // `speed_limit_bps = 0` on a speed_limit rule means unlimited, and has to + // beat the user's configured 5 MB/s rather than fall back to it. + let mut unlimited = rule(ScheduleAction::SpeedLimit); + unlimited.speed_limit_bps = 0; + let body = update(&state, true, vec![unlimited]).await; + + assert_eq!(body["speed_limit_bps"], 0); + assert_eq!( + state.queue_manager.get_speed_limit(), + 0, + "an unlimited window still ran at the configured limit" + ); + assert_eq!( + state.queue_manager.configured_speed_limit(), + 5_000_000, + "the user's configured limit must be kept for after the window" + ); + + update(&state, false, vec![]).await; + assert_eq!(state.queue_manager.get_speed_limit(), 5_000_000); +} + +#[tokio::test] +async fn a_server_rule_disables_servers_in_the_live_list_only() { + let mut primary = ServerConfig::new("primary", "news.primary.test"); + primary.name = "Primary".into(); + let mut backup = ServerConfig::new("backup", "news.backup.test"); + backup.name = "Backup".into(); + let (state, temp) = test_state_with_servers(vec![primary, backup]); + + let mut disable = rule(ScheduleAction::DisableServers); + disable.servers = vec!["backup".into()]; + let body = update(&state, true, vec![disable]).await; + assert_eq!(body["disabled_servers"][0], "backup"); + + let live = state.queue_manager.live_servers(); + let backup = live.iter().find(|s| s.id == "backup").unwrap(); + let primary = live.iter().find(|s| s.id == "primary").unwrap(); + assert!( + !backup.enabled, + "the schedule should take backup out of rotation" + ); + assert!(primary.enabled); + + // The configured list, and the file, still say the user's choice. + assert!(state.config().servers.iter().all(|s| s.enabled)); + let saved = AppConfig::load(&temp.path().join("config.toml")).unwrap(); + assert!(saved.servers.iter().all(|s| s.enabled)); + + update(&state, false, vec![]).await; + assert!(state.queue_manager.live_servers().iter().all(|s| s.enabled)); +} diff --git a/apps/rustnzb/tests/support/mod.rs b/apps/rustnzb/tests/support/mod.rs index 571b83a1..04e0188a 100644 --- a/apps/rustnzb/tests/support/mod.rs +++ b/apps/rustnzb/tests/support/mod.rs @@ -93,6 +93,7 @@ async fn start_test_server_inner( config_version: base.config_version, notifications: base.notifications, par2: base.par2, + schedule: base.schedule, }; let db = Database::open_memory().expect("Failed to create in-memory database"); diff --git a/crates/nzb-core/src/config.rs b/crates/nzb-core/src/config.rs index 9a3c68e8..fd3e3f31 100644 --- a/crates/nzb-core/src/config.rs +++ b/crates/nzb-core/src/config.rs @@ -27,6 +27,8 @@ pub struct AppConfig { pub notifications: NotificationConfig, #[serde(default)] pub par2: Par2Config, + #[serde(default)] + pub schedule: crate::schedule::ScheduleConfig, } impl Default for AppConfig { @@ -41,6 +43,7 @@ impl Default for AppConfig { dav: DavConfig::default(), notifications: NotificationConfig::default(), par2: Par2Config::default(), + schedule: crate::schedule::ScheduleConfig::default(), } } } @@ -636,6 +639,11 @@ impl AppConfig { problems.push(format!("{label}: duplicate server id")); } } + for (i, rule) in self.schedule.rules.iter().enumerate() { + if let Some(problem) = crate::schedule::rule_problem(rule) { + problems.push(format!("schedule.rules[{i}] ({}): {problem}", rule.id)); + } + } for (i, feed) in self.rss_feeds.iter().enumerate() { if let Some(pattern) = feed.filter_regex.as_deref() && let Err(e) = regex::Regex::new(pattern) @@ -1168,6 +1176,24 @@ ssl = false } } + #[test] + fn validate_names_a_bad_schedule_rule_by_index_and_id() { + let mut cfg = AppConfig::default(); + cfg.schedule.rules.push(crate::schedule::ScheduleRule { + id: "quiet".into(), + days: vec!["funday".into()], + ..Default::default() + }); + + let problems = cfg.validate(); + assert!( + problems + .iter() + .any(|p| p.starts_with("schedule.rules[0] (quiet):") && p.contains("funday")), + "{problems:?}" + ); + } + #[test] fn default_path_is_absolute() { assert!(AppConfig::default_path().is_absolute()); diff --git a/crates/nzb-core/src/lib.rs b/crates/nzb-core/src/lib.rs index 3fdd47b2..8951f07f 100644 --- a/crates/nzb-core/src/lib.rs +++ b/crates/nzb-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod overrides; pub mod par2_match; pub mod path; pub mod sabnzbd_import; +pub mod schedule; pub use config::AppConfig; pub use damage_ledger::{ diff --git a/crates/nzb-core/src/schedule.rs b/crates/nzb-core/src/schedule.rs new file mode 100644 index 00000000..638016ad --- /dev/null +++ b/crates/nzb-core/src/schedule.rs @@ -0,0 +1,442 @@ +//! Time-of-day download schedule. +//! +//! A rule names a set of weekdays and a window, and what should happen inside +//! it: pause downloads, cap the speed, or take news servers out of rotation. +//! Windows may cross midnight; such a window belongs to the day it starts on. +//! +//! Evaluation is a pure function of the rules and a wall-clock time, so the +//! behaviour around midnight and at window edges is testable without a clock, +//! a timer or a running queue. + +use chrono::{Datelike, NaiveDateTime, NaiveTime, Timelike, Weekday}; +use serde::{Deserialize, Serialize}; + +/// What an active rule does to the queue. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScheduleAction { + /// Pause all downloads. + #[default] + Pause, + /// Apply a speed limit for the window. + SpeedLimit, + /// Take the listed servers out of rotation. + DisableServers, +} + +/// The whole `[schedule]` section. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ScheduleConfig { + /// Rules are only evaluated when this is on. Turning it off restores the + /// configured speed limit, resumes a pause the schedule applied, and + /// returns every server to rotation. + pub enabled: bool, + pub rules: Vec, +} + +/// One time-of-day window. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ScheduleRule { + /// Stable identifier, assigned by the API when a rule is created. + pub id: String, + /// Label shown in the UI. + pub name: String, + /// Weekdays the window starts on: `mon`..`sun` (case-insensitive, full + /// names are accepted too). + pub days: Vec, + /// Window start as `HH:MM`, 24-hour clock. + pub start: String, + /// Window end as `HH:MM`. Earlier than `start` means the window runs past + /// midnight. `24:00` is accepted as the end of the day. + pub end: String, + pub action: ScheduleAction, + /// Speed cap in bytes/sec for `speed_limit`; `0` means unlimited, which + /// explicitly overrides a lower cap from another active rule. + pub speed_limit_bps: u64, + /// Server ids taken out of rotation for `disable_servers`. + pub servers: Vec, +} + +impl Default for ScheduleRule { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + days: Vec::new(), + start: "00:00".into(), + end: "24:00".into(), + action: ScheduleAction::Pause, + speed_limit_bps: 0, + servers: Vec::new(), + } + } +} + +/// What the schedule wants right now. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ScheduleDecision { + /// Rules active at the evaluated time, in configuration order. + pub active_rule_ids: Vec, + /// Whether downloads should be paused. Any active pause rule pauses. + pub pause: bool, + /// Effective scheduled cap. `None` means no scheduled cap, so the + /// configured `general.speed_limit_bps` applies. `Some(0)` means an + /// active rule asked for unlimited speed, which overrides both the + /// configured limit and any lower cap from another rule. + pub speed_limit_bps: Option, + /// Server ids to take out of rotation, in configuration order and without + /// duplicates. + pub disabled_servers: Vec, +} + +impl ScheduleDecision { + /// Nothing to do: no pause, no cap and every server enabled. + pub fn is_idle(&self) -> bool { + !self.pause && self.speed_limit_bps.is_none() && self.disabled_servers.is_empty() + } +} + +/// Parse a weekday name. +pub fn parse_weekday(name: &str) -> Option { + let name = name.trim().to_ascii_lowercase(); + Some(match name.as_str() { + "mon" | "monday" => Weekday::Mon, + "tue" | "tues" | "tuesday" => Weekday::Tue, + "wed" | "weds" | "wednesday" => Weekday::Wed, + "thu" | "thur" | "thurs" | "thursday" => Weekday::Thu, + "fri" | "friday" => Weekday::Fri, + "sat" | "saturday" => Weekday::Sat, + "sun" | "sunday" => Weekday::Sun, + _ => return None, + }) +} + +/// Parse `HH:MM` into minutes since midnight. `24:00` is accepted as 1440. +pub fn parse_minutes(value: &str) -> Option { + let (hour, minute) = value.trim().split_once(':')?; + if hour.len() != 2 || minute.len() != 2 { + return None; + } + let hour: u32 = hour.parse().ok()?; + let minute: u32 = minute.parse().ok()?; + if minute > 59 { + return None; + } + match (hour, minute) { + (24, 0) => Some(24 * 60), + (0..=23, _) => Some(hour * 60 + minute), + _ => None, + } +} + +/// Why a rule cannot be evaluated, phrased for the settings page. +pub fn rule_problem(rule: &ScheduleRule) -> Option { + if rule.days.is_empty() { + return Some("pick at least one day".into()); + } + if let Some(day) = rule.days.iter().find(|day| parse_weekday(day).is_none()) { + return Some(format!("`{day}` is not a weekday")); + } + let Some(start) = parse_minutes(&rule.start) else { + return Some(format!("start `{}` is not HH:MM", rule.start)); + }; + let Some(end) = parse_minutes(&rule.end) else { + return Some(format!("end `{}` is not HH:MM", rule.end)); + }; + if start >= 24 * 60 { + return Some("start must be earlier than 24:00".into()); + } + if start == end { + return Some("start and end must differ".into()); + } + if rule.action == ScheduleAction::DisableServers && rule.servers.is_empty() { + return Some("pick at least one server".into()); + } + None +} + +fn is_active_on(rule: &ScheduleRule, now: NaiveDateTime) -> bool { + let (Some(start), Some(end)) = (parse_minutes(&rule.start), parse_minutes(&rule.end)) else { + return false; + }; + let days: Vec = rule + .days + .iter() + .filter_map(|day| parse_weekday(day)) + .collect(); + let time = now.time(); + let minute = time.hour() * 60 + time.minute(); + + if start < end { + return days.contains(&now.weekday()) && minute >= start && minute < end; + } + + // The window runs past midnight. The day it belongs to is the start day, + // so the tail belongs to the previous day's entry. + let tail_of_yesterday = now + .date() + .pred_opt() + .is_some_and(|yesterday| days.contains(&yesterday.weekday())); + (days.contains(&now.weekday()) && minute >= start) || (tail_of_yesterday && minute < end) +} + +/// Evaluate every rule against `now` (local wall-clock time). +/// +/// Invalid rules are skipped: they cannot be saved through the API, and a +/// hand-edited file with one must not take the queue down with it. +pub fn evaluate(rules: &[ScheduleRule], now: NaiveDateTime) -> ScheduleDecision { + let mut decision = ScheduleDecision::default(); + let mut saw_unlimited = false; + + for rule in rules { + if rule_problem(rule).is_some() || !is_active_on(rule, now) { + continue; + } + decision.active_rule_ids.push(rule.id.clone()); + match rule.action { + ScheduleAction::Pause => decision.pause = true, + ScheduleAction::SpeedLimit => { + if rule.speed_limit_bps == 0 { + saw_unlimited = true; + } else { + decision.speed_limit_bps = Some( + decision + .speed_limit_bps + .map_or(rule.speed_limit_bps, |bps| bps.min(rule.speed_limit_bps)), + ); + } + } + ScheduleAction::DisableServers => { + for server in &rule.servers { + if !decision.disabled_servers.contains(server) { + decision.disabled_servers.push(server.clone()); + } + } + } + } + } + + // An active rule that says "unlimited" wins over a cap: it is an explicit + // statement about this window, not the absence of one. It has to be + // `Some(0)`, not `None` -- `None` means "no scheduled cap", which would + // let the configured `general.speed_limit_bps` through and run the window + // at the user's limit instead of unlimited. + if saw_unlimited { + decision.speed_limit_bps = Some(0); + } + decision +} + +/// Evaluate a whole config: a disabled schedule is always idle. +pub fn evaluate_config(config: &ScheduleConfig, now: NaiveDateTime) -> ScheduleDecision { + if !config.enabled { + return ScheduleDecision::default(); + } + evaluate(&config.rules, now) +} + +/// The next minute boundary after `now`, used to align the monitor's wakeups. +pub fn next_minute(now: NaiveTime) -> NaiveTime { + let minute = now.hour() * 60 + now.minute(); + let next = (minute + 1) % (24 * 60); + NaiveTime::from_hms_opt(next / 60, next % 60, 0).expect("valid minute of day") +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + fn at(day: u32, hour: u32, minute: u32) -> NaiveDateTime { + NaiveDate::from_ymd_opt(2026, 10, day) + .unwrap() + .and_hms_opt(hour, minute, 0) + .unwrap() + } + + fn rule(days: &[&str], start: &str, end: &str, action: ScheduleAction) -> ScheduleRule { + ScheduleRule { + id: format!("{start}-{end}"), + name: String::new(), + days: days.iter().map(|d| (*d).to_string()).collect(), + start: start.into(), + end: end.into(), + action, + ..ScheduleRule::default() + } + } + + #[test] + fn parses_clock_times_and_rejects_bad_ones() { + assert_eq!(parse_minutes("00:00"), Some(0)); + assert_eq!(parse_minutes("09:05"), Some(545)); + assert_eq!(parse_minutes("23:59"), Some(1439)); + assert_eq!(parse_minutes("24:00"), Some(1440)); + assert_eq!(parse_minutes("24:01"), None); + assert_eq!(parse_minutes("7:00"), None); + assert_eq!(parse_minutes("07:0"), None); + assert_eq!(parse_minutes("07:60"), None); + assert_eq!(parse_minutes("noon"), None); + } + + #[test] + fn parses_weekday_names_case_insensitively() { + assert_eq!(parse_weekday("Mon"), Some(Weekday::Mon)); + assert_eq!(parse_weekday("thursday"), Some(Weekday::Thu)); + assert_eq!(parse_weekday("sunday "), Some(Weekday::Sun)); + assert_eq!(parse_weekday("funday"), None); + } + + #[test] + fn a_daytime_window_covers_its_start_and_excludes_its_end() { + let rules = [rule(&["tue"], "09:00", "17:00", ScheduleAction::Pause)]; + // 2026-10-20 is a Tuesday. + assert!(!evaluate(&rules, at(20, 8, 59)).pause); + assert!(evaluate(&rules, at(20, 9, 0)).pause); + assert!(evaluate(&rules, at(20, 16, 59)).pause); + assert!(!evaluate(&rules, at(20, 17, 0)).pause); + assert!(!evaluate(&rules, at(21, 12, 0)).pause, "wrong weekday"); + } + + #[test] + fn an_overnight_window_belongs_to_the_day_it_starts_on() { + let rules = [rule(&["mon"], "22:00", "06:00", ScheduleAction::Pause)]; + // Monday 2026-10-19 at 23:00 is inside; Tuesday 05:00 is the tail. + assert!(evaluate(&rules, at(19, 23, 0)).pause); + assert!(evaluate(&rules, at(20, 5, 59)).pause); + assert!(!evaluate(&rules, at(20, 6, 0)).pause, "tail ends at 06:00"); + assert!( + !evaluate(&rules, at(20, 22, 30)).pause, + "Tuesday starts nothing" + ); + assert!(!evaluate(&rules, at(19, 21, 59)).pause); + } + + #[test] + fn a_full_day_window_is_active_all_day() { + let rules = [rule(&["sat"], "00:00", "24:00", ScheduleAction::Pause)]; + assert!(evaluate(&rules, at(24, 0, 0)).pause); + assert!(evaluate(&rules, at(24, 23, 59)).pause); + assert!(!evaluate(&rules, at(25, 0, 0)).pause); + } + + #[test] + fn the_lowest_applied_cap_wins_and_unlimited_overrides_one() { + let mut off_peak = rule(&["tue"], "00:00", "24:00", ScheduleAction::SpeedLimit); + off_peak.id = "off-peak".into(); + off_peak.speed_limit_bps = 1_000_000; + let mut strict = rule(&["tue"], "00:00", "24:00", ScheduleAction::SpeedLimit); + strict.id = "strict".into(); + strict.speed_limit_bps = 500_000; + + let decision = evaluate(&[off_peak.clone(), strict.clone()], at(20, 12, 0)); + assert_eq!(decision.speed_limit_bps, Some(500_000)); + assert_eq!(decision.active_rule_ids, vec!["off-peak", "strict"]); + + let mut unlimited = rule(&["tue"], "00:00", "24:00", ScheduleAction::SpeedLimit); + unlimited.id = "unlimited".into(); + let decision = evaluate(&[off_peak, strict, unlimited], at(20, 12, 0)); + assert_eq!( + decision.speed_limit_bps, + Some(0), + "unlimited is an explicit 0 cap, not the absence of one" + ); + } + + #[test] + fn an_unlimited_rule_is_distinct_from_having_no_cap_rule() { + let mut unlimited = rule(&["tue"], "00:00", "24:00", ScheduleAction::SpeedLimit); + unlimited.id = "unlimited".into(); + assert_eq!( + evaluate(&[unlimited], at(20, 12, 0)).speed_limit_bps, + Some(0) + ); + // With no cap rule at all the configured limit must still apply, so + // this is the case that has to stay `None`. + assert_eq!(evaluate(&[], at(20, 12, 0)).speed_limit_bps, None); + } + + #[test] + fn a_cap_outside_its_window_does_not_apply() { + let mut cap = rule(&["tue"], "01:00", "05:00", ScheduleAction::SpeedLimit); + cap.speed_limit_bps = 1_000_000; + assert_eq!(evaluate(&[cap], at(20, 12, 0)).speed_limit_bps, None); + } + + #[test] + fn disabled_servers_are_collected_once_in_rule_order() { + let mut first = rule(&["tue"], "00:00", "24:00", ScheduleAction::DisableServers); + first.id = "a".into(); + first.servers = vec!["backup".into(), "primary".into()]; + let mut second = rule(&["tue"], "00:00", "24:00", ScheduleAction::DisableServers); + second.id = "b".into(); + second.servers = vec!["backup".into()]; + + let decision = evaluate(&[first, second], at(20, 12, 0)); + assert_eq!(decision.disabled_servers, vec!["backup", "primary"]); + } + + #[test] + fn a_disabled_schedule_is_idle_whatever_the_clock_says() { + let config = ScheduleConfig { + enabled: false, + rules: vec![rule(&["tue"], "00:00", "24:00", ScheduleAction::Pause)], + }; + assert!(evaluate_config(&config, at(20, 12, 0)).is_idle()); + let config = ScheduleConfig { + enabled: true, + ..config + }; + assert!(!evaluate_config(&config, at(20, 12, 0)).is_idle()); + } + + #[test] + fn invalid_rules_are_skipped_rather_than_applied() { + let broken = ScheduleRule { + id: "broken".into(), + days: vec!["mon".into()], + start: "nope".into(), + end: "06:00".into(), + ..ScheduleRule::default() + }; + assert!(evaluate(&[broken], at(19, 23, 0)).is_idle()); + } + + #[test] + fn validation_names_the_field_at_fault() { + let mut bad_day = rule(&["funday"], "09:00", "17:00", ScheduleAction::Pause); + assert!(rule_problem(&bad_day).unwrap().contains("funday")); + bad_day.days = vec!["mon".into()]; + assert!(rule_problem(&bad_day).is_none()); + + let mut bad_time = bad_day.clone(); + bad_time.start = "9:00".into(); + assert!(rule_problem(&bad_time).unwrap().contains("HH:MM")); + + let mut empty = bad_day.clone(); + empty.days.clear(); + assert!(rule_problem(&empty).unwrap().contains("at least one day")); + + let mut same = bad_day.clone(); + same.end = "09:00".into(); + assert!(rule_problem(&same).unwrap().contains("must differ")); + + let mut no_servers = bad_day.clone(); + no_servers.action = ScheduleAction::DisableServers; + assert!(rule_problem(&no_servers).unwrap().contains("server")); + } + + #[test] + fn next_minute_wraps_at_midnight() { + assert_eq!( + next_minute(NaiveTime::from_hms_opt(10, 15, 30).unwrap()), + NaiveTime::from_hms_opt(10, 16, 0).unwrap() + ); + assert_eq!( + next_minute(NaiveTime::from_hms_opt(23, 59, 10).unwrap()), + NaiveTime::from_hms_opt(0, 0, 0).unwrap() + ); + } +} diff --git a/crates/nzb-web/src/lib.rs b/crates/nzb-web/src/lib.rs index 7ed28438..b412af8f 100644 --- a/crates/nzb-web/src/lib.rs +++ b/crates/nzb-web/src/lib.rs @@ -16,6 +16,7 @@ pub mod nzb_archive; pub mod queue_manager; pub mod rss_monitor; pub mod sabnzbd_compat; +pub mod schedule_monitor; pub mod startup; pub mod state; pub mod util; diff --git a/crates/nzb-web/src/queue_manager.rs b/crates/nzb-web/src/queue_manager.rs index 7482b1a1..d4e985ca 100644 --- a/crates/nzb-web/src/queue_manager.rs +++ b/crates/nzb-web/src/queue_manager.rs @@ -1641,6 +1641,9 @@ pub enum PauseReason { DiskSpace, /// The job was paused after a news-server error. Server, + /// A time-of-day schedule rule is pausing downloads. The pause lifts + /// automatically when the window ends. + Schedule, } /// Notification fired immediately when a job is accepted into the queue. @@ -1729,6 +1732,26 @@ pub struct QueueManager { /// guard rather than the user; holds when the guard tripped. The guard /// lifts only pauses it owns. disk_space_hold: Mutex>>, + /// True while the current global pause was applied by the download + /// schedule. Like the disk hold, only the schedule may lift it, and only + /// while it still owns the pause. + schedule_hold: AtomicBool, + /// True while a pause rule is open. The schedule pauses on the rising + /// edge of this only: the user may resume inside a window, and that + /// resume holds until the window closes and the next one opens. + schedule_window: AtomicBool, + /// Servers the schedule has taken out of rotation, as configured by the + /// user. The live list is the configured list with these disabled. + schedule_disabled_servers: Mutex>, + /// The configured server list, before the schedule's overlay. Held so the + /// overlay can be recomputed whenever either side changes. + configured_servers: Mutex>, + /// The user's configured speed limit, which the schedule's cap overlays. + /// Zero means unlimited. + configured_speed_limit_bps: AtomicU64, + /// The cap the schedule currently imposes, if any. Overlays the + /// configured limit; `None` means the configured limit applies. + scheduled_speed_cap: Mutex>, /// Global speed tracker. speed: SpeedTracker, /// Database for persistence. @@ -1796,6 +1819,13 @@ pub struct QueueManager { impl QueueManager { const GLOBAL_PAUSED_JOBS_SETTING: &'static str = "globally_paused_job_ids"; const DISK_SPACE_HOLD_SETTING: &'static str = "disk_space_hold_since"; + /// Who applied the persisted global pause: empty or `user`, or + /// `schedule`. A restart must not turn a scheduled pause into a user + /// pause the schedule can never lift. + const PAUSE_OWNER_SETTING: &'static str = "global_pause_owner"; + const PAUSE_OWNER_USER: &'static str = "user"; + const PAUSE_OWNER_DISK: &'static str = "disk_space"; + const PAUSE_OWNER_SCHEDULE: &'static str = "schedule"; const QUEUE_COMPLETE_ACTION_SETTING: &'static str = "queue_complete_action"; /// Create a new queue manager. @@ -1863,6 +1893,7 @@ impl QueueManager { let download_bps = NonZeroU32::new(u32::try_from(speed_limit_bps).unwrap_or(u32::MAX)); let bandwidth = Arc::new(BandwidthLimiter::new(BandwidthConfig { download_bps })); + let configured_servers = servers.clone(); let servers_arc = Arc::new(Mutex::new(servers)); let dispatch: Arc = Arc::new(DispatchHandle::new( Arc::clone(&servers_arc), @@ -1882,6 +1913,12 @@ impl QueueManager { pause_transition: Mutex::new(()), globally_paused_jobs: Mutex::new(HashSet::new()), disk_space_hold: Mutex::new(None), + schedule_hold: AtomicBool::new(false), + schedule_window: AtomicBool::new(false), + schedule_disabled_servers: Mutex::new(Vec::new()), + configured_servers: Mutex::new(configured_servers), + configured_speed_limit_bps: AtomicU64::new(speed_limit_bps), + scheduled_speed_cap: Mutex::new(None), speed: SpeedTracker::new(), db: Mutex::new(db), incomplete_dir: Mutex::new(incomplete_dir), @@ -2312,11 +2349,35 @@ impl QueueManager { self.postproc_resources.snapshot() } - /// Set the download speed limit in bytes per second (0 = unlimited). + /// Set the configured download speed limit in bytes per second + /// (0 = unlimited). + /// + /// This is the user's value. An active schedule cap overrides it until + /// the window ends, at which point this value comes back. pub fn set_speed_limit(&self, bps: u64) { + self.configured_speed_limit_bps.store(bps, Ordering::SeqCst); + self.apply_effective_speed_limit(); + } + + /// The user's configured speed limit, before any schedule cap. + pub fn configured_speed_limit(&self) -> u64 { + self.configured_speed_limit_bps.load(Ordering::SeqCst) + } + + /// Apply the schedule's cap over the configured limit. `None` lifts it. + fn set_scheduled_speed_cap(&self, cap: Option) { + *self.scheduled_speed_cap.lock() = cap; + self.apply_effective_speed_limit(); + } + + fn apply_effective_speed_limit(&self) { use std::num::NonZeroU32; + let effective = self + .scheduled_speed_cap + .lock() + .unwrap_or(self.configured_speed_limit()); // Saturate rather than wrap: `as u32` turns 4 GiB/s + 1 into 1 B/s. - let limit = NonZeroU32::new(u32::try_from(bps).unwrap_or(u32::MAX)); + let limit = NonZeroU32::new(u32::try_from(effective).unwrap_or(u32::MAX)); self.bandwidth.set_download_bps(limit); } @@ -4833,12 +4894,32 @@ impl QueueManager { /// disk-space guard; `None` is a user pause, which also takes ownership /// of any existing disk hold so the guard will not lift it. fn pause_all_with_hold(&self, disk_space_hold: Option>) { + let owner = if disk_space_hold.is_some() { + Self::PAUSE_OWNER_DISK + } else { + Self::PAUSE_OWNER_USER + }; + self.pause_all_as(disk_space_hold, owner); + } + + /// Pause everything and record who owns the pause. The owner decides who + /// may lift it: the schedule lifts only its own, the disk guard only its + /// hold, and a user pause is lifted only by `resume_all`. + fn pause_all_as(&self, disk_space_hold: Option>, owner: &str) { let _transition = self.pause_transition.lock(); + // Ownership is persisted with the pause, or a restart would hand a + // user's pause back to the schedule. + self.schedule_hold + .store(owner == Self::PAUSE_OWNER_SCHEDULE, Ordering::SeqCst); // Publish the global gate before touching individual jobs. This // prevents every scheduling and per-job resume path from starting // more work while the active contexts are being paused. self.globally_paused.store(true, Ordering::SeqCst); - self.db.lock().set_setting("globally_paused", "true"); + { + let db = self.db.lock(); + db.set_setting("globally_paused", "true"); + db.set_setting(Self::PAUSE_OWNER_SETTING, owner); + } self.set_disk_space_hold(disk_space_hold); // A plain pause is indefinite: cancel any pending timed resume. // `pause_for` sets its deadline after calling this. @@ -4909,6 +4990,64 @@ impl QueueManager { info!(duration_secs, "Paused for duration"); } + /// Apply a schedule decision to the queue. + /// + /// The schedule only owns what it applied: it will not resume a pause the + /// user or the disk guard started, and it lifts its own pause only when + /// the window ends. Speed caps and server exclusions are overlays — the + /// configured values are never rewritten. + pub fn apply_schedule( + self: &Arc, + decision: &crate::nzb_core::schedule::ScheduleDecision, + enabled: bool, + ) { + let idle = crate::nzb_core::schedule::ScheduleDecision::default(); + let decision = if enabled { decision } else { &idle }; + // Pause ownership, edge-triggered: a pause rule pauses when its window + // opens, not on every tick while it is open. A manual resume inside the + // window therefore holds until the next window -- the tick must not + // re-pause what the user just resumed. Ownership still decides who may + // lift the pause: the schedule lifts only its own. + let was_in_window = self.schedule_window.swap(decision.pause, Ordering::SeqCst); + if decision.pause && !was_in_window { + if !self.is_paused() { + self.pause_all_as(None, Self::PAUSE_OWNER_SCHEDULE); + info!("Schedule window opened, pausing downloads"); + } + } else if !decision.pause && was_in_window { + if self.is_paused() && self.schedule_hold.load(Ordering::SeqCst) { + self.resume_all(); + info!("Schedule window ended, resuming downloads"); + } else { + // A user pause (or a disk hold) took ownership during the + // window: the schedule no longer holds anything to lift. + self.schedule_hold.store(false, Ordering::SeqCst); + } + } + + // Speed cap: only touch the limiter when the value changes, so a + // running download does not see a needless limiter update each tick. + if self.scheduled_speed_cap.lock().as_ref() != decision.speed_limit_bps.as_ref() { + self.set_scheduled_speed_cap(decision.speed_limit_bps); + } + + // Server exclusions. + if *self.schedule_disabled_servers.lock() != decision.disabled_servers { + *self.schedule_disabled_servers.lock() = decision.disabled_servers.clone(); + self.apply_server_overlay(); + } + } + + /// Whether the schedule currently holds the global pause. + pub fn schedule_pause_active(&self) -> bool { + self.schedule_hold.load(Ordering::SeqCst) + } + + /// The speed cap the schedule is applying right now, if any. + pub fn scheduled_speed_cap(&self) -> Option { + *self.scheduled_speed_cap.lock() + } + /// Get remaining pause time in seconds (None if not timed). pub fn pause_remaining_secs(&self) -> Option { let until = self.pause_until.lock(); @@ -4921,8 +5060,13 @@ impl QueueManager { /// Resume all downloads globally. pub fn resume_all(self: &Arc) { let _transition = self.pause_transition.lock(); + self.schedule_hold.store(false, Ordering::SeqCst); self.globally_paused.store(false, Ordering::SeqCst); - self.db.lock().set_setting("globally_paused", "false"); + { + let db = self.db.lock(); + db.set_setting("globally_paused", "false"); + db.set_setting(Self::PAUSE_OWNER_SETTING, ""); + } self.set_disk_space_hold(None); *self.pause_until.lock() = None; @@ -4962,12 +5106,34 @@ impl QueueManager { /// server errors (e.g. auth failure / service unavailable) are /// automatically resumed. pub fn update_servers(self: &Arc, servers: Vec) { - let enabled = servers.iter().filter(|s| s.enabled).count(); - info!(total = servers.len(), enabled, "Updating server list"); + info!(total = servers.len(), "Updating server list"); + *self.configured_servers.lock() = servers; + self.apply_server_overlay(); + } + + /// Push the configured list, with the schedule's exclusions disabled, to + /// the dispatcher. + fn apply_server_overlay(self: &Arc) { + let disabled = self.schedule_disabled_servers.lock().clone(); + let effective: Vec = { + let configured = self.configured_servers.lock(); + configured + .iter() + .map(|server| { + let mut server = server.clone(); + if disabled.iter().any(|id| id == &server.id) { + server.enabled = false; + } + server + }) + .collect() + }; + let enabled = effective.iter().filter(|s| s.enabled).count(); + info!(total = effective.len(), enabled, "Applying server list"); - self.dispatch.update_servers(servers); + self.dispatch.update_servers(effective); - // Auto-resume jobs paused by server errors now that config changed + // Auto-resume jobs paused by server errors now that servers are back. if enabled > 0 { self.resume_server_paused_jobs(); } @@ -5007,11 +5173,22 @@ impl QueueManager { } } - /// Get current server configs. + /// Get the configured server list, before the schedule's overlay. pub fn get_servers(&self) -> Vec { + self.configured_servers.lock().clone() + } + + /// The server list as the dispatcher sees it: configured servers with + /// the schedule's exclusions disabled. + pub fn live_servers(&self) -> Vec { self.servers.lock().clone() } + /// Servers the schedule currently has out of rotation. + pub fn schedule_disabled_servers(&self) -> Vec { + self.schedule_disabled_servers.lock().clone() + } + // ----------------------------------------------------------------------- // Query methods (for API handlers) // ----------------------------------------------------------------------- @@ -5097,6 +5274,8 @@ impl QueueManager { } Some(if self.disk_space_hold.lock().is_some() { PauseReason::DiskSpace + } else if self.schedule_hold.load(Ordering::SeqCst) { + PauseReason::Schedule } else { PauseReason::Global }) @@ -5950,7 +6129,7 @@ impl QueueManager { pub fn restore_from_db(self: &Arc) -> crate::nzb_core::Result<()> { prune_damage_ledger(&self.db.lock()); // Restore globally_paused from persisted state - let (was_paused, persisted_global_ids) = { + let (was_paused, persisted_global_ids, pause_owner) = { let db = self.db.lock(); let paused = db .get_setting("globally_paused") @@ -5958,7 +6137,10 @@ impl QueueManager { let ids = db .get_setting(Self::GLOBAL_PAUSED_JOBS_SETTING) .and_then(|value| serde_json::from_str::>(&value).ok()); - (paused, ids) + let owner = db + .get_setting(Self::PAUSE_OWNER_SETTING) + .unwrap_or_default(); + (paused, ids, owner) }; if was_paused { self.globally_paused.store(true, Ordering::SeqCst); @@ -5969,8 +6151,17 @@ impl QueueManager { .and_then(|value| DateTime::parse_from_rfc3339(&value).ok()) .map(|at| at.with_timezone(&Utc)); *self.disk_space_hold.lock() = hold; + if hold.is_none() && pause_owner == Self::PAUSE_OWNER_SCHEDULE { + self.schedule_hold.store(true, Ordering::SeqCst); + // Rebuild the window state too: the pause was applied inside a + // window, so its end must still be able to lift it. Left + // false, the first tick after a restart would report "no + // change" and the queue would stay paused indefinitely. + self.schedule_window.store(true, Ordering::SeqCst); + } info!( disk_space_hold = hold.is_some(), + schedule_hold = self.schedule_hold.load(Ordering::SeqCst), "Restored global pause state from database" ); } @@ -7639,6 +7830,300 @@ mod global_pause_tests { assert!(manager.is_paused()); } + #[tokio::test] + async fn schedule_pause_ownership_survives_a_restart() { + let (manager, tempdir) = manager(); + insert_job(&manager, job("queued", JobStatus::Queued, tempdir.path())); + + let decision = crate::nzb_core::schedule::ScheduleDecision { + active_rule_ids: vec!["quiet".into()], + pause: true, + ..Default::default() + }; + manager.apply_schedule(&decision, true); + assert!(manager.is_paused()); + assert!(manager.schedule_pause_active()); + assert_eq!(manager.global_pause_reason(), Some(PauseReason::Schedule)); + assert_eq!( + manager + .db + .lock() + .get_setting("global_pause_owner") + .as_deref(), + Some("schedule") + ); + + // A restart rebuilds the pause from the database. The schedule must + // still own it, or the window ending could never resume the queue. + let restarted = Arc::clone(&manager); + restarted.restore_from_db().unwrap(); + assert!(restarted.is_paused()); + assert!(restarted.schedule_pause_active()); + assert_eq!(restarted.global_pause_reason(), Some(PauseReason::Schedule)); + + // A user Pause All during the window takes ownership, so the window + // ending leaves their pause alone. + restarted.pause_all(); + assert!(!restarted.schedule_pause_active()); + assert_eq!(restarted.global_pause_reason(), Some(PauseReason::Global)); + // Close and reopen the window: a user pause is not the schedule's to + // lift, so reopening must not replace the user's ownership with its + // own. + restarted.apply_schedule( + &crate::nzb_core::schedule::ScheduleDecision::default(), + true, + ); + restarted.apply_schedule(&decision, true); + assert!( + restarted.is_paused(), + "a user pause is not the schedule's to lift" + ); + assert_eq!( + restarted + .db + .lock() + .get_setting("global_pause_owner") + .as_deref(), + Some("user") + ); + } + + #[tokio::test] + async fn a_manual_resume_inside_a_window_holds_until_the_window_closes() { + let (manager, tempdir) = manager(); + insert_job(&manager, job("queued", JobStatus::Queued, tempdir.path())); + + let open = crate::nzb_core::schedule::ScheduleDecision { + active_rule_ids: vec!["quiet".into()], + pause: true, + ..Default::default() + }; + manager.apply_schedule(&open, true); + assert!(manager.is_paused()); + assert_eq!(manager.global_pause_reason(), Some(PauseReason::Schedule)); + + // The user overrides the schedule for this window. + manager.resume_all(); + assert!(!manager.is_paused()); + assert!(!manager.schedule_pause_active()); + + // Every subsequent tick inside the window sees the same decision. The + // pause is edge-triggered, so none of them may re-pause the queue. + for _ in 0..5 { + manager.apply_schedule(&open, true); + assert!( + !manager.is_paused(), + "a manual resume must survive the rest of the window" + ); + } + + // When the window closes the schedule has nothing to lift, and the + // next window opening pauses again. + manager.apply_schedule( + &crate::nzb_core::schedule::ScheduleDecision::default(), + true, + ); + assert!(!manager.is_paused()); + manager.apply_schedule(&open, true); + assert!(manager.is_paused()); + assert_eq!(manager.global_pause_reason(), Some(PauseReason::Schedule)); + } + + #[tokio::test] + async fn a_window_opening_while_the_user_is_paused_leaves_the_user_owner() { + let (manager, tempdir) = manager(); + insert_job(&manager, job("queued", JobStatus::Queued, tempdir.path())); + + manager.pause_all(); + let open = crate::nzb_core::schedule::ScheduleDecision { + pause: true, + ..Default::default() + }; + manager.apply_schedule(&open, true); + assert_eq!(manager.global_pause_reason(), Some(PauseReason::Global)); + assert!(!manager.schedule_pause_active()); + + // The window ending must not resume a pause the user owns. + manager.apply_schedule( + &crate::nzb_core::schedule::ScheduleDecision::default(), + true, + ); + assert!( + manager.is_paused(), + "a user pause outlives the window that opened during it" + ); + assert_eq!(manager.global_pause_reason(), Some(PauseReason::Global)); + } + + #[tokio::test] + async fn a_restart_inside_a_window_still_lifts_the_pause_when_it_ends() { + let (manager, _tempdir) = manager(); + let open = crate::nzb_core::schedule::ScheduleDecision { + pause: true, + ..Default::default() + }; + manager.apply_schedule(&open, true); + assert!(manager.schedule_pause_active()); + + // A restart restores the schedule-owned pause from the database. The + // window must be rebuilt with it, or the end of the window would be + // read as "no change" and the queue would stay paused forever. + let restarted = Arc::clone(&manager); + restarted.restore_from_db().unwrap(); + assert!(restarted.schedule_pause_active()); + + restarted.apply_schedule( + &crate::nzb_core::schedule::ScheduleDecision::default(), + true, + ); + assert!( + !restarted.is_paused(), + "the window ending after a restart still resumes the queue" + ); + assert!(!restarted.schedule_pause_active()); + } + + #[tokio::test] + async fn disabling_the_schedule_inside_a_window_lifts_its_pause() { + let (manager, tempdir) = manager(); + insert_job(&manager, job("queued", JobStatus::Queued, tempdir.path())); + + let open = crate::nzb_core::schedule::ScheduleDecision { + pause: true, + ..Default::default() + }; + manager.apply_schedule(&open, true); + assert!(manager.is_paused()); + + // Turning the schedule off substitutes an idle decision, which is the + // window closing from the queue's point of view. + manager.apply_schedule(&open, false); + assert!(!manager.is_paused()); + assert!(!manager.schedule_pause_active()); + + // And re-enabling it opens a fresh window. + manager.apply_schedule(&open, true); + assert!(manager.is_paused()); + assert_eq!(manager.global_pause_reason(), Some(PauseReason::Schedule)); + } + + #[tokio::test] + async fn schedule_speed_cap_overlays_the_configured_limit_without_rewriting_it() { + let (manager, _tempdir) = manager(); + manager.set_speed_limit(5_000_000); + assert_eq!(manager.configured_speed_limit(), 5_000_000); + + let cap = crate::nzb_core::schedule::ScheduleDecision { + speed_limit_bps: Some(1_000_000), + ..Default::default() + }; + manager.apply_schedule(&cap, true); + assert_eq!(manager.scheduled_speed_cap(), Some(1_000_000)); + assert_eq!(manager.configured_speed_limit(), 5_000_000); + // The limiter the downloader reads is the cap, not the user's value. + assert_eq!(manager.get_speed_limit(), 1_000_000); + + manager.apply_schedule( + &crate::nzb_core::schedule::ScheduleDecision::default(), + true, + ); + assert_eq!(manager.scheduled_speed_cap(), None); + assert_eq!(manager.configured_speed_limit(), 5_000_000); + // ...and ending the window puts the user's limit back on the wire. + assert_eq!(manager.get_speed_limit(), 5_000_000); + } + + #[tokio::test] + async fn a_schedule_cap_of_zero_means_unlimited_even_over_a_configured_limit() { + let (manager, _tempdir) = manager(); + manager.set_speed_limit(5_000_000); + + let cap = crate::nzb_core::schedule::ScheduleDecision { + speed_limit_bps: Some(0), + ..Default::default() + }; + manager.apply_schedule(&cap, true); + assert_eq!(manager.scheduled_speed_cap(), Some(0)); + assert_eq!( + manager.get_speed_limit(), + 0, + "an explicit 0 cap lifts the user's limit for the window" + ); + + manager.apply_schedule( + &crate::nzb_core::schedule::ScheduleDecision::default(), + true, + ); + assert_eq!(manager.get_speed_limit(), 5_000_000); + } + + #[tokio::test] + async fn a_disabled_schedule_applies_nothing_and_lifts_an_active_cap() { + let (manager, _tempdir) = manager(); + manager.set_speed_limit(5_000_000); + + let cap = crate::nzb_core::schedule::ScheduleDecision { + speed_limit_bps: Some(1_000_000), + disabled_servers: vec!["backup".into()], + pause: true, + ..Default::default() + }; + manager.apply_schedule(&cap, true); + assert_eq!(manager.get_speed_limit(), 1_000_000); + assert!(manager.schedule_pause_active()); + + // Toggling the schedule off must release everything it applied, even + // though the decision it is handed still says otherwise. + manager.apply_schedule(&cap, false); + assert_eq!(manager.scheduled_speed_cap(), None); + assert_eq!(manager.get_speed_limit(), 5_000_000); + assert!(manager.schedule_disabled_servers().is_empty()); + assert!(!manager.schedule_pause_active()); + assert!(!manager.is_paused()); + } + + #[tokio::test] + async fn schedule_server_exclusions_leave_the_configured_list_alone() { + let (manager, _tempdir) = manager(); + let server = |id: &str| { + let mut config = ServerConfig::new(id, format!("{id}.example.com")); + config.enabled = true; + config + }; + manager.update_servers(vec![server("primary"), server("backup")]); + assert!(manager.get_servers().iter().all(|s| s.enabled)); + + let decision = crate::nzb_core::schedule::ScheduleDecision { + disabled_servers: vec!["backup".into()], + ..Default::default() + }; + manager.apply_schedule(&decision, true); + assert_eq!(manager.schedule_disabled_servers(), vec!["backup"]); + assert!( + manager + .get_servers() + .iter() + .find(|s| s.id == "backup") + .is_some_and(|s| s.enabled), + "the configured list keeps its own enabled flags" + ); + assert!( + manager + .live_servers() + .iter() + .find(|s| s.id == "backup") + .is_some_and(|s| !s.enabled), + "the dispatcher sees the exclusion" + ); + + manager.apply_schedule( + &crate::nzb_core::schedule::ScheduleDecision::default(), + true, + ); + assert!(manager.schedule_disabled_servers().is_empty()); + assert!(manager.live_servers().iter().all(|s| s.enabled)); + } + #[tokio::test] async fn preflight_storage_hold_reports_disk_space_and_resumes_when_space_frees() { let (manager, tempdir) = manager(); diff --git a/crates/nzb-web/src/schedule_monitor.rs b/crates/nzb-web/src/schedule_monitor.rs new file mode 100644 index 00000000..6a11dc5e --- /dev/null +++ b/crates/nzb-web/src/schedule_monitor.rs @@ -0,0 +1,161 @@ +//! Background monitor that applies the time-of-day download schedule. +//! +//! The evaluator in [`nzb_core::schedule`] is pure; this task is the only +//! part that knows about the clock. It re-evaluates at every minute boundary +//! (so windows start and end promptly) and immediately whenever the config +//! changes, so enabling a rule or editing its window takes effect at once. + +use std::sync::Arc; + +use arc_swap::ArcSwap; +use chrono::{Local, Timelike}; +use tokio::sync::Notify; +use tokio::time::Duration; +use tracing::debug; + +use crate::nzb_core::config::AppConfig; +use crate::nzb_core::schedule::{ScheduleDecision, evaluate_config}; +use crate::queue_manager::QueueManager; + +pub struct ScheduleMonitor { + config: Arc>, + queue_manager: Arc, + /// Woken on every committed config change. + wake: Arc, + /// The last decision applied. Re-applying an identical decision is a + /// no-op, so the log stays quiet while a window is open. + last: ScheduleDecision, +} + +impl ScheduleMonitor { + pub fn new(config: Arc>, queue_manager: Arc) -> Self { + Self { + config, + queue_manager, + wake: Arc::new(Notify::new()), + last: ScheduleDecision::default(), + } + } + + pub fn with_wake(mut self, wake: Arc) -> Self { + self.wake = wake; + self + } + + /// Evaluate once against the current config and wall clock, applying the + /// decision. Returns the decision, for tests and startup. + pub fn apply_now(&mut self) -> ScheduleDecision { + let config = self.config.load(); + let decision = evaluate_config(&config.schedule, Local::now().naive_local()); + if decision != self.last { + debug!( + active_rules = ?decision.active_rule_ids, + pause = decision.pause, + speed_limit_bps = ?decision.speed_limit_bps, + disabled_servers = ?decision.disabled_servers, + "Schedule decision changed" + ); + } + self.queue_manager + .apply_schedule(&decision, config.schedule.enabled); + self.last = decision.clone(); + decision + } + + pub async fn run(mut self) { + self.apply_now(); + loop { + // Sleep to the next minute boundary so window edges are not + // delayed by a long fixed interval, and wake early on a config + // change. A minute is the finest resolution the schedule has. + let now = Local::now(); + let into_minute = Duration::from_secs(now.second() as u64) + + Duration::from_nanos(now.nanosecond() as u64); + let until_next = Duration::from_secs(60) + .saturating_sub(into_minute) + .max(Duration::from_millis(50)); + tokio::select! { + _ = self.wake.notified() => {} + _ = tokio::time::sleep(until_next) => {} + } + self.apply_now(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::log_buffer::LogBuffer; + use crate::nzb_core::config::ServerConfig; + use crate::nzb_core::db::Database; + use crate::nzb_core::schedule::{ScheduleAction, ScheduleRule}; + use crate::queue_manager::PauseReason; + + fn manager() -> (Arc, tempfile::TempDir) { + let tempdir = tempfile::tempdir().expect("tempdir"); + let manager = QueueManager::new( + Vec::::new(), + Database::open_memory().expect("database"), + tempdir.path().join("incomplete"), + tempdir.path().join("complete"), + LogBuffer::default(), + 1, + Vec::new(), + 0, + 0, + false, + 5, + false, + false, + 100.0, + 30, + ); + (manager, tempdir) + } + + fn all_day_pause() -> ScheduleRule { + ScheduleRule { + id: "always".into(), + name: "Always".into(), + days: vec![ + "mon".into(), + "tue".into(), + "wed".into(), + "thu".into(), + "fri".into(), + "sat".into(), + "sun".into(), + ], + start: "00:00".into(), + end: "24:00".into(), + action: ScheduleAction::Pause, + ..ScheduleRule::default() + } + } + + #[tokio::test] + async fn apply_now_pauses_and_resumes_as_the_config_changes() { + let (manager, _tempdir) = manager(); + let mut config = AppConfig::default(); + config.schedule.enabled = true; + config.schedule.rules = vec![all_day_pause()]; + let config = Arc::new(ArcSwap::from_pointee(config)); + let mut monitor = ScheduleMonitor::new(Arc::clone(&config), Arc::clone(&manager)); + + let decision = monitor.apply_now(); + assert_eq!(decision.active_rule_ids, vec!["always"]); + assert!(manager.is_paused()); + assert!(manager.schedule_pause_active()); + assert_eq!(manager.global_pause_reason(), Some(PauseReason::Schedule)); + + // Turning the schedule off lifts the pause it applied. + let mut next = AppConfig::default(); + next.schedule.enabled = false; + next.schedule.rules = vec![all_day_pause()]; + config.store(Arc::new(next)); + assert!(monitor.apply_now().is_idle()); + assert!(!manager.is_paused()); + assert!(!manager.schedule_pause_active()); + } +} diff --git a/crates/nzb-web/src/startup.rs b/crates/nzb-web/src/startup.rs index a3d550cd..f044804b 100644 --- a/crates/nzb-web/src/startup.rs +++ b/crates/nzb-web/src/startup.rs @@ -418,13 +418,24 @@ pub async fn initialize( // checked promptly instead of after the monitor's current sleep. let data_dir_for_rss = shared_config.load().general.data_dir.clone(); let monitor = crate::rss_monitor::RssMonitor::new( - shared_config, + Arc::clone(&shared_config), Arc::clone(&queue_manager), data_dir_for_rss, ) .with_wake(Arc::clone(&state.rss_monitor_wake)); tokio::spawn(async move { monitor.run().await }); + // Apply the schedule once before serving, then keep it up to date. The + // monitor owns its own config handle so a rule edit is picked up on the + // next tick even if the wake is missed. + let schedule_config = shared_config.clone(); + let schedule_monitor = crate::schedule_monitor::ScheduleMonitor::new( + schedule_config, + Arc::clone(&state.queue_manager), + ) + .with_wake(Arc::clone(&state.schedule_wake)); + tokio::spawn(async move { schedule_monitor.run().await }); + Ok(StartupResult { state, queue_manager, diff --git a/crates/nzb-web/src/state.rs b/crates/nzb-web/src/state.rs index c5107981..de989fa8 100644 --- a/crates/nzb-web/src/state.rs +++ b/crates/nzb-web/src/state.rs @@ -40,6 +40,10 @@ pub struct AppState { /// re-reads its feeds at once instead of finishing its current sleep. /// Single consumer: `notify_one` stores a permit if the monitor is busy. pub rss_monitor_wake: Arc, + /// Signalled after a committed change to the download schedule, so the + /// schedule monitor applies it at once instead of at the next minute + /// boundary. + pub schedule_wake: Arc, } impl AppState { @@ -64,6 +68,7 @@ impl AppState { file: Mutex::new(file), overrides: OverrideSet::default(), rss_monitor_wake: Arc::new(tokio::sync::Notify::new()), + schedule_wake: Arc::new(tokio::sync::Notify::new()), } } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 64aef165..0be28a82 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -238,6 +238,46 @@ A fresh config has one category, `Default`. | `auto_download` | `false` | Queue every item that passes `filter_regex`, without a rule. | | `max_age_days` | unset | Ignore items older than this. | +## `[schedule]` + +Time-of-day rules for pausing downloads, capping the speed and taking news +servers out of rotation. Edit it in Settings → Scheduler or write it here. + +| Key | Default | Description | +|-----|---------|-------------| +| `enabled` | `false` | Evaluate the rules. Turning it off lifts only a pause the schedule applied. | +| `rules` | `[]` | One table per window. | + +Each `[[schedule.rules]]` entry: + +| Key | Default | Description | +|-----|---------|-------------| +| `id` | assigned by the API | Stable identifier. The UI leaves it blank on a new rule and the API fills it in. | +| `name` | `""` | Label shown in the settings page and the queue. | +| `days` | — | Weekdays the window **starts** on: `mon`..`sun`, or full names. Required. | +| `start`, `end` | `"00:00"`, `"24:00"` | `HH:MM`, 24-hour clock. `end` earlier than `start` runs overnight; `24:00` ends the day. | +| `action` | `"pause"` | `pause`, `speed_limit` or `disable_servers`. | +| `speed_limit_bps` | `0` | Cap for `speed_limit`, in bytes/sec. `0` means unlimited and overrides a lower cap from another active rule. | +| `servers` | `[]` | Server ids taken out of rotation for `disable_servers`. | + +Rules are evaluated against the process's **local** wall clock: inside a +container, set `TZ` to have windows follow your timezone. + +The schedule is an overlay. It never rewrites `general.speed_limit_bps` or a +server's `enabled` flag — the configured values come back when the window +ends, and the servers page keeps showing what you configured. The same is true +of pauses: the schedule only resumes a pause it applied, so a manual pause, a +timed pause or a low-disk hold is never lifted by a window ending. Overlapping +caps use the lowest non-zero value, and an active `0` cap means unlimited, +overriding both `general.speed_limit_bps` and any lower cap from another rule. + +A `pause` rule is **edge-triggered**: it pauses when its window opens, not on +every minute while the window is open. Resuming downloads by hand inside a +window therefore holds until that window closes, and the next window opening +pauses again — the schedule does not fight you back within a minute. A +`Pause All` pressed inside a window takes ownership of the pause, so the end of +the window will not resume it. + ## `[par2]` | Key | Default | Description | diff --git a/e2e/tests/settings.spec.ts b/e2e/tests/settings.spec.ts index 0b5a70a5..8db552b5 100644 --- a/e2e/tests/settings.spec.ts +++ b/e2e/tests/settings.spec.ts @@ -120,3 +120,60 @@ test.describe('8. Settings — General', () => { await page.locator('label:has-text("Concurrent jobs") + .inline button').click(); }); }); + +test.describe('8a. Settings — Scheduler', () => { + test('8a.1 adds, saves, reloads and removes a rule', async ({ page }) => { + await page.goto('/settings'); + await page.getByRole('button', { name: 'Scheduler' }).click(); + + await expect(page.locator('h2', { hasText: 'Scheduler' })).toBeVisible(); + + // Clear anything a previous run left behind, so the journey is repeatable. + while ((await page.getByRole('button', { name: 'Remove' }).count()) > 0) { + await page.getByRole('button', { name: 'Remove' }).first().click(); + await page.getByRole('button', { name: 'Save schedule' }).click(); + await expect(page.getByText('Schedule saved')).toBeVisible(); + } + await expect(page.getByText('No rules yet')).toBeVisible(); + + await page.getByRole('button', { name: '+ Add rule' }).click(); + const name = page.locator('label:has-text("Name") + .inline input[type="text"]').first(); + await name.fill('E2E quiet hours'); + + // Friday is selected by default; the window is 01:00-06:00. + await expect(page.locator('.schedule-day', { hasText: 'Fri' }).locator('input')).toBeChecked(); + + await page.getByRole('button', { name: 'Save schedule' }).click(); + await expect(page.getByText('Schedule saved')).toBeVisible(); + + // A reload comes back from the API with the server-assigned id and the + // saved name and times. + await page.reload(); + await page.getByRole('button', { name: 'Scheduler' }).click(); + await expect(page.locator('.panel h3', { hasText: 'E2E quiet hours' })).toBeVisible(); + const start = page.locator('input[aria-label="Start time"]').first(); + await expect(start).toHaveValue('01:00'); + await expect(page.locator('input[aria-label="End time"]').first()).toHaveValue('06:00'); + + await page.getByRole('button', { name: 'Remove' }).first().click(); + await page.getByRole('button', { name: 'Save schedule' }).click(); + await expect(page.getByText('Schedule saved')).toBeVisible(); + await expect(page.getByText('No rules yet')).toBeVisible(); + }); + + test('8a.2 refuses a malformed window instead of saving it', async ({ page }) => { + await page.goto('/settings'); + await page.getByRole('button', { name: 'Scheduler' }).click(); + await page.getByRole('button', { name: '+ Add rule' }).click(); + + const start = page.locator('input[aria-label="Start time"]').first(); + await start.fill('25:00'); + await expect(page.getByText('Start must be HH:MM')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Save schedule' })).toBeDisabled(); + + await start.fill('01:00'); + await expect(page.getByRole('button', { name: 'Save schedule' })).toBeEnabled(); + await page.getByRole('button', { name: 'Discard changes' }).click(); + await expect(page.getByText('No rules yet')).toBeVisible(); + }); +});