Adding download window which can prevent downloads from running at unwanted times.
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run

This commit is contained in:
Roland 2026-03-12 20:10:57 -06:00
parent 4579562be5
commit 35994bd363
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
10 changed files with 395 additions and 57 deletions

View file

@ -0,0 +1,122 @@
describe('Download Window Settings', () => {
const mockSettings = {
archive_destination: 'archive/%(domain_tag)s/%(domain)s',
download_manager_disabled: false,
download_manager_stopped: false,
download_on_startup: true,
download_timeout: 0,
download_wait: 20,
download_window_start: null,
download_window_end: null,
hotspot_device: 'wlan0',
hotspot_on_startup: true,
hotspot_password: 'wrolpi hotspot',
hotspot_ssid: 'WROLPi',
check_for_upgrades: true,
ignore_outdated_zims: false,
log_level: 'info',
map_destination: 'map',
nav_color: 'violet',
media_directory: '/media/wrolpi',
tags_directory: true,
throttle_on_startup: false,
version: '1.0.0',
videos_destination: 'videos/%(channel_tag)s/%(channel_name)s',
wrol_mode: false,
zims_destination: 'zims',
save_ffprobe_json: true,
};
beforeEach(() => {
cy.intercept('GET', '/api/status', {
statusCode: 200,
body: {
version: '1.0.0',
flags: {},
cpu_percent: 10,
memory_percent: 30,
downloads: {pending: 0, recurring: 0, disabled: false, stopped: false, outside_download_window: false},
}
}).as('getStatus');
cy.intercept('GET', '/api/tags', {
statusCode: 200,
body: {tags: []}
}).as('getTags');
cy.intercept('GET', '/api/events', {
statusCode: 200,
body: {events: []}
}).as('getEvents');
cy.intercept('POST', '/api/search/suggestions', {
statusCode: 200,
body: {fileGroups: 0, zimsEstimates: [], channels: [], domains: []}
}).as('getSuggestions');
});
it('time inputs are empty when no window is configured', () => {
cy.intercept('GET', '/api/settings', {
statusCode: 200,
body: {...mockSettings},
}).as('getSettings');
cy.visit('/admin/settings');
cy.wait('@getSettings');
cy.get('input[type="time"]').should('have.length', 2);
cy.get('input[type="time"]').first().should('have.value', '');
cy.get('input[type="time"]').last().should('have.value', '');
});
it('displays saved download window values from API', () => {
cy.intercept('GET', '/api/settings', {
statusCode: 200,
body: {
...mockSettings,
download_window_start: '08:00',
download_window_end: '17:00',
},
}).as('getSettings');
cy.visit('/admin/settings');
cy.wait('@getSettings');
cy.get('input[type="time"]').should('have.length', 2);
cy.get('input[type="time"]').first().should('have.value', '08:00');
cy.get('input[type="time"]').last().should('have.value', '17:00');
});
it('can set download window values', () => {
cy.intercept('GET', '/api/settings', {
statusCode: 200,
body: {...mockSettings},
}).as('getSettings');
cy.visit('/admin/settings');
cy.wait('@getSettings');
cy.get('input[type="time"]').first().type('08:00');
cy.get('input[type="time"]').first().should('have.value', '08:00');
cy.get('input[type="time"]').last().type('17:00');
cy.get('input[type="time"]').last().should('have.value', '17:00');
});
it('displays overnight window values from API', () => {
cy.intercept('GET', '/api/settings', {
statusCode: 200,
body: {
...mockSettings,
download_window_start: '22:00',
download_window_end: '06:00',
},
}).as('getSettings');
cy.visit('/admin/settings');
cy.wait('@getSettings');
cy.get('input[type="time"]').first().should('have.value', '22:00');
cy.get('input[type="time"]').last().should('have.value', '06:00');
});
});

View file

@ -302,6 +302,8 @@ export async function getSettings() {
download_on_startup: content.download_on_startup,
download_timeout: content.download_timeout,
download_wait: content.download_wait,
download_window_start: content.download_window_start,
download_window_end: content.download_window_end,
hotspot_device: content.hotspot_device,
hotspot_on_startup: content.hotspot_on_startup,
hotspot_password: content.hotspot_password,

View file

@ -591,6 +591,17 @@ export function WROLModeMessage({content}) {
return null;
}
export function DownloadWindowMessage() {
const {status} = React.useContext(StatusContext);
const downloads = status ? status.downloads : null;
if (downloads && downloads.outside_download_window) {
return <Message icon='clock outline' header='Outside Download Window'
content='Downloads are paused because the current time is outside the configured download window.'/>
}
return null;
}
export function CookiesUnlockModal({open, onClose, onSuccess}) {
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);

View file

@ -15,6 +15,7 @@ import {
APIButton,
CookiesLockedMessage,
DisableDownloadsToggle,
DownloadWindowMessage,
ErrorMessage,
formatFrequency,
humanBandwidth,
@ -681,6 +682,7 @@ export function DownloadsPage() {
return <>
<WROLModeMessage content='Downloads are disabled because WROL Mode is enabled.'/>
<DownloadWindowMessage/>
<DisableDownloadsToggle/>
<CookiesLockedMessage/>

View file

@ -443,6 +443,8 @@ export function SettingsPage() {
download_on_startup: settings.download_on_startup,
download_timeout: settings.download_timeout,
download_wait: settings.download_wait,
download_window_start: settings.download_window_start || '',
download_window_end: settings.download_window_end || '',
hotspot_device: settings.hotspot_device,
hotspot_on_startup: settings.hotspot_on_startup,
hotspot_password: settings.hotspot_password,
@ -562,34 +564,46 @@ export function SettingsPage() {
/>
</div>
<Form.Group inline>
<Form.Input
label={<>
<b>Wait Between Downloads</b>
<InfoPopup
content='Number of seconds to wait after each download completes. This helps download "nicely" by being polite to servers. Set to 0 to disable waiting.'/>
</>}
value={state.download_wait}
disabled={disabled || state.download_wait === null}
onChange={(e, i) => handleTimeoutChange(e, 'download_wait', i.value)}
/>
</Form.Group>
<br/>
<Form.Group inline>
<Form.Input
label={<>
<b>Download Timeout</b>
<InfoPopup content='Downloads will be stopped after this many seconds have elapsed.
Downloads will never timeout if this is empty.'/>
</>}
value={state.download_timeout}
disabled={disabled || state.download_timeout === null}
onChange={(e, i) => handleTimeoutChange(e, 'download_timeout', i.value)}
<label htmlFor='log_levels_input'>Log Level: {logLevelToName(state.log_level)}</label>
<br/>
<input type='range'
id='log_levels_input'
list='log_levels'
min='1'
max='5'
value={state.log_level}
onChange={e => setState({...state, log_level: parseInt(e.target.value)})}
style={{marginBottom: '1em'}}
/>
<datalist id='log_levels'>
<option value='1'>Critical</option>
<option value='2'>Warning</option>
<option value='3'>Info</option>
<option value='4'>Debug</option>
<option value='5'>Trace</option>
</datalist>
<br/>
<ButtonGroup>
<Button color={state.nav_color} onClick={e => e.preventDefault()}>Navbar Color</Button>
<Dropdown
id='settings_navbar_color_dropdown'
className='button icon'
floating
options={navColorOptions}
onChange={(e, {value}) => setState({...state, nav_color: value})}
value={state.nav_color}
/>
</Form.Group>
</ButtonGroup>
</Grid.Column>
<Grid.Column>
<Segment>
<Header as='h3'>Hotspot Settings</Header>
<Form.Input
label='Hotspot SSID'
value={state.hotspot_ssid}
@ -638,40 +652,61 @@ export function SettingsPage() {
</Grid>
<br/>
<Divider/>
<Header as='h3'>Download settings</Header>
<label htmlFor='log_levels_input'>Log Level: {logLevelToName(state.log_level)}</label>
<br/>
<input type='range'
id='log_levels_input'
list='log_levels'
min='1'
max='5'
value={state.log_level}
onChange={e => setState({...state, log_level: parseInt(e.target.value)})}
style={{marginBottom: '1em'}}
/>
<datalist id='log_levels'>
<option value='1'>Critical</option>
<option value='2'>Warning</option>
<option value='3'>Info</option>
<option value='4'>Debug</option>
<option value='5'>Trace</option>
</datalist>
<br/>
<ButtonGroup>
<Button color={state.nav_color} onClick={e => e.preventDefault()}>Navbar Color</Button>
<Dropdown
id='settings_navbar_color_dropdown'
className='button icon'
floating
options={navColorOptions}
onChange={(e, {value}) => setState({...state, nav_color: value})}
value={state.nav_color}
<Form.Group inline>
<Form.Input
label={<>
<b>Wait Between Downloads</b>
<InfoPopup
content='Number of seconds to wait after each download completes. This helps download "nicely" by being polite to servers. Set to 0 to disable waiting.'/>
</>}
value={state.download_wait}
disabled={disabled || state.download_wait === null}
onChange={(e, i) => handleTimeoutChange(e, 'download_wait', i.value)}
/>
</ButtonGroup>
</Form.Group>
<Form.Group inline>
<Form.Input
label={<>
<b>Download Timeout</b>
<InfoPopup content='Downloads will be stopped after this many seconds have elapsed.
Downloads will never timeout if this is empty.'/>
</>}
value={state.download_timeout}
disabled={disabled || state.download_timeout === null}
onChange={(e, i) => handleTimeoutChange(e, 'download_timeout', i.value)}
/>
</Form.Group>
<Form.Group inline>
<Form.Input
label={<>
<b>Download Window Start</b>
<InfoPopup
content='Start time for allowed download window (HH:MM, 24-hour). Leave both empty for no restriction.'/>
</>}
type='time'
value={state.download_window_start}
disabled={disabled}
onChange={(e, i) => handleInputChange(e, 'download_window_start', i.value)}
/>
</Form.Group>
<Form.Group inline>
<Form.Input
label={<>
<b>Download Window End</b>
<InfoPopup
content='End time for allowed download window (HH:MM, 24-hour). Leave both empty for no restriction.'/>
</>}
type='time'
value={state.download_window_end}
disabled={disabled}
onChange={(e, i) => handleInputChange(e, 'download_window_end', i.value)}
/>
</Form.Group>
<Divider/>

View file

@ -792,6 +792,8 @@ class WROLPiConfigValidator:
download_on_startup: bool = None
download_timeout: int = None
download_wait: int = None
download_window_start: str = None
download_window_end: str = None
hotspot_device: str = None
hotspot_on_startup: bool = None
hotspot_password: str = None
@ -932,6 +934,8 @@ class WROLPiConfig(ConfigFile):
download_on_startup=True,
download_timeout=0,
download_wait=20,
download_window_start=None,
download_window_end=None,
hotspot_device='wlan0',
hotspot_on_startup=True,
hotspot_password='wrolpi hotspot',
@ -1018,6 +1022,22 @@ class WROLPiConfig(ConfigFile):
def download_wait(self, value: int):
self.update({'download_wait': value})
@property
def download_window_start(self) -> str:
return self._config.get('download_window_start')
@download_window_start.setter
def download_window_start(self, value: str):
self.update({'download_window_start': value})
@property
def download_window_end(self) -> str:
return self._config.get('download_window_end')
@download_window_end.setter
def download_window_end(self, value: str):
self.update({'download_window_end': value})
@property
def hotspot_device(self) -> str:
return self._config['hotspot_device']

View file

@ -423,15 +423,18 @@ class Downloader:
try:
while not task.done():
if download_manager.download_is_killed(download_id) or not download_manager.can_download:
outside_window = download_manager.outside_download_window
logger.warning(f'Cancel download of {download.url}')
task.cancel()
try:
await task
except asyncio.CancelledError as e:
logger.info(f'Successful cancel of {download.url}', exc_info=e)
error = 'Download paused: outside download window' if outside_window \
else 'Download was canceled'
return DownloadResult(
success=False,
error='Download was canceled',
error=error,
)
finally:
download_manager.unkill_download(download_id)
@ -592,6 +595,28 @@ class DownloadManager:
self.stopped.clear()
self.disabled.clear()
def is_within_download_window(self) -> bool:
"""Returns True if current time is within the configured download window, or no window is configured."""
config = get_wrolpi_config()
start = config.download_window_start
end = config.download_window_end
if not start or not end:
return True
current = now().time()
start_time = datetime.strptime(start, "%H:%M").time()
end_time = datetime.strptime(end, "%H:%M").time()
if start_time <= end_time:
return start_time <= current < end_time
else:
# Overnight window (e.g., 22:00-06:00)
return current >= start_time or current < end_time
@property
def outside_download_window(self) -> bool:
return not self.is_within_download_window()
@property
def can_download(self) -> bool:
"""Returns True only if all steps necessary for downloading have been met."""
@ -609,6 +634,9 @@ class DownloadManager:
if wrol_mode_enabled():
# Do not download with WROL Mode enabled.
return False
if not self.is_within_download_window():
# Do not download outside the configured time window.
return False
if get_download_manager_config().successful_import:
# Finally, allow downloading because config was valid and imported.
return True
@ -1158,6 +1186,7 @@ class DownloadManager:
recurring=counts['recurring_downloads'],
disabled=self.is_disabled,
stopped=self.is_stopped,
outside_download_window=self.outside_download_window,
)
return summary
@ -1424,7 +1453,11 @@ async def signal_download_download(download_id: int, download_url: str):
download_manager.create_downloads(session, urls, downloader_name=download.sub_downloader,
settings=child_settings)
if try_again is False and not download.frequency:
if result.error and 'outside download window' in result.error:
# Download was paused due to window closing; reset to new so it resumes later.
download.renew()
download.error = result.error
elif try_again is False and not download.frequency:
# Only once-downloads can fail.
download.fail()
elif result.success:

View file

@ -157,6 +157,8 @@ def get_settings(_: Request):
'download_on_startup': wrolpi_config.download_on_startup,
'download_timeout': wrolpi_config.download_timeout,
'download_wait': wrolpi_config.download_wait,
'download_window_start': wrolpi_config.download_window_start,
'download_window_end': wrolpi_config.download_window_end,
'hotspot_device': wrolpi_config.hotspot_device,
'hotspot_on_startup': wrolpi_config.hotspot_on_startup,
'hotspot_password': wrolpi_config.hotspot_password,
@ -230,6 +232,17 @@ async def update_settings(_: Request, body: schema.SettingsRequest):
elif not body.zims_destination:
new_config['zims_destination'] = wrolpi_config.default_config['zims_destination']
# Handle download window fields: empty string clears the value.
for field_name in ('download_window_start', 'download_window_end'):
value = getattr(body, field_name, None)
if value is not None:
if value == '':
new_config[field_name] = None
else:
if not re.match(r'^\d{2}:\d{2}$', value):
raise InvalidConfig(f'{field_name} must be in HH:MM format')
new_config[field_name] = value
log_level = new_config.pop('log_level', None)
if isinstance(log_level, int):
set_global_log_level(log_level)

View file

@ -19,6 +19,8 @@ class SettingsResponse:
download_on_startup: bool
download_timeout: int
download_wait: int
download_window_start: Optional[str]
download_window_end: Optional[str]
hotspot_device: str
hotspot_on_startup: bool
hotspot_password: str
@ -61,6 +63,8 @@ class SettingsRequest:
download_on_startup: Optional[bool] = None
download_timeout: Optional[int] = None
download_wait: Optional[int] = None
download_window_start: Optional[str] = None
download_window_end: Optional[str] = None
hotspot_device: Optional[str] = None
hotspot_on_startup: Optional[bool] = None
hotspot_password: Optional[str] = None

View file

@ -1042,3 +1042,99 @@ def test_yaml_dump_keeps_overrides(test_session, test_download_manager, test_dow
assert settings['writesubtitles'] is False, 'Non-default writesubtitles should be kept'
finally:
set_test_downloader_config(False)
def test_is_within_download_window_no_config(test_download_manager):
"""Returns True when no download window is configured."""
config = get_wrolpi_config()
config.download_window_start = None
config.download_window_end = None
assert test_download_manager.is_within_download_window() is True
# Only one field set should also return True (no restriction).
config.download_window_start = '08:00'
config.download_window_end = None
assert test_download_manager.is_within_download_window() is True
config.download_window_start = None
config.download_window_end = '17:00'
assert test_download_manager.is_within_download_window() is True
def test_is_within_download_window_same_day(test_download_manager, fake_now):
"""Same-day window (e.g. 08:00-17:00) boundary checks."""
config = get_wrolpi_config()
config.download_window_start = '08:00'
config.download_window_end = '17:00'
# Before window
fake_now(datetime(2000, 1, 1, 7, 59))
assert test_download_manager.is_within_download_window() is False
# Start of window
fake_now(datetime(2000, 1, 1, 8, 0))
assert test_download_manager.is_within_download_window() is True
# Middle of window
fake_now(datetime(2000, 1, 1, 12, 0))
assert test_download_manager.is_within_download_window() is True
# End of window (exclusive)
fake_now(datetime(2000, 1, 1, 17, 0))
assert test_download_manager.is_within_download_window() is False
# After window
fake_now(datetime(2000, 1, 1, 23, 0))
assert test_download_manager.is_within_download_window() is False
def test_is_within_download_window_overnight(test_download_manager, fake_now):
"""Overnight window (e.g. 22:00-06:00) wrapping."""
config = get_wrolpi_config()
config.download_window_start = '22:00'
config.download_window_end = '06:00'
# Before window (daytime)
fake_now(datetime(2000, 1, 1, 12, 0))
assert test_download_manager.is_within_download_window() is False
# Just before window start
fake_now(datetime(2000, 1, 1, 21, 59))
assert test_download_manager.is_within_download_window() is False
# Start of window
fake_now(datetime(2000, 1, 1, 22, 0))
assert test_download_manager.is_within_download_window() is True
# Middle of night
fake_now(datetime(2000, 1, 2, 3, 0))
assert test_download_manager.is_within_download_window() is True
# End of window (exclusive)
fake_now(datetime(2000, 1, 2, 6, 0))
assert test_download_manager.is_within_download_window() is False
def test_download_window_renews_download(test_session, test_download_manager, test_downloader):
"""A download stopped by the window closing is renewed to 'new' status."""
from wrolpi.downloader import DownloadResult
download = test_download_manager.create_download(test_session, 'https://example.com/window', test_downloader.name)
download.started()
test_session.commit()
# Simulate the result that cancel_wrapper returns when outside the window.
result = DownloadResult(success=False, error='Download paused: outside download window')
# Apply the same logic as signal_download_download.
download.location = result.location or download.location or None
download.error = result.error if result.error else None
if result.error and 'outside download window' in result.error:
download.renew()
download.error = result.error
test_session.commit()
test_session.refresh(download)
assert download.status == 'new'
assert 'outside download window' in download.error