From 35994bd363e8a080c05efc0bca84b2bbbaf9397a Mon Sep 17 00:00:00 2001 From: Roland Date: Thu, 12 Mar 2026 20:10:57 -0600 Subject: [PATCH] Adding download window which can prevent downloads from running at unwanted times. --- .../e2e/settings/download-window.cy.js | 122 +++++++++++++++ app/src/api.js | 2 + app/src/components/Common.js | 11 ++ app/src/components/admin/Downloads.js | 2 + app/src/components/admin/Settings.js | 145 +++++++++++------- wrolpi/common.py | 20 +++ wrolpi/downloader.py | 37 ++++- wrolpi/root_api.py | 13 ++ wrolpi/schema.py | 4 + wrolpi/test/test_downloader.py | 96 ++++++++++++ 10 files changed, 395 insertions(+), 57 deletions(-) create mode 100644 app/cypress/e2e/settings/download-window.cy.js diff --git a/app/cypress/e2e/settings/download-window.cy.js b/app/cypress/e2e/settings/download-window.cy.js new file mode 100644 index 00000000..94e373ea --- /dev/null +++ b/app/cypress/e2e/settings/download-window.cy.js @@ -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'); + }); +}); diff --git a/app/src/api.js b/app/src/api.js index fda9dc59..4986b350 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -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, diff --git a/app/src/components/Common.js b/app/src/components/Common.js index 7792d1fc..52628c24 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -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 + } + return null; +} + export function CookiesUnlockModal({open, onClose, onSuccess}) { const [password, setPassword] = useState(''); const [submitting, setSubmitting] = useState(false); diff --git a/app/src/components/admin/Downloads.js b/app/src/components/admin/Downloads.js index 230a14ab..1277f860 100644 --- a/app/src/components/admin/Downloads.js +++ b/app/src/components/admin/Downloads.js @@ -15,6 +15,7 @@ import { APIButton, CookiesLockedMessage, DisableDownloadsToggle, + DownloadWindowMessage, ErrorMessage, formatFrequency, humanBandwidth, @@ -681,6 +682,7 @@ export function DownloadsPage() { return <> + diff --git a/app/src/components/admin/Settings.js b/app/src/components/admin/Settings.js index f040a32a..def32ab0 100644 --- a/app/src/components/admin/Settings.js +++ b/app/src/components/admin/Settings.js @@ -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() { /> - - - Wait Between Downloads - - } - value={state.download_wait} - disabled={disabled || state.download_wait === null} - onChange={(e, i) => handleTimeoutChange(e, 'download_wait', i.value)} - /> - +
- - - Download Timeout - - } - value={state.download_timeout} - disabled={disabled || state.download_timeout === null} - onChange={(e, i) => handleTimeoutChange(e, 'download_timeout', i.value)} + +
+ setState({...state, log_level: parseInt(e.target.value)})} + style={{marginBottom: '1em'}} + /> + + + + + + + + +
+ + + + setState({...state, nav_color: value})} + value={state.nav_color} /> -
+ + +
Hotspot Settings
+ -
+ +
Download settings
- -
- setState({...state, log_level: parseInt(e.target.value)})} - style={{marginBottom: '1em'}} - /> - - - - - - - - -
- - - - setState({...state, nav_color: value})} - value={state.nav_color} + + + Wait Between Downloads + + } + value={state.download_wait} + disabled={disabled || state.download_wait === null} + onChange={(e, i) => handleTimeoutChange(e, 'download_wait', i.value)} /> - + + + + Download Timeout + + } + value={state.download_timeout} + disabled={disabled || state.download_timeout === null} + onChange={(e, i) => handleTimeoutChange(e, 'download_timeout', i.value)} + /> + + + + + Download Window Start + + } + type='time' + value={state.download_window_start} + disabled={disabled} + onChange={(e, i) => handleInputChange(e, 'download_window_start', i.value)} + /> + + + + + Download Window End + + } + type='time' + value={state.download_window_end} + disabled={disabled} + onChange={(e, i) => handleInputChange(e, 'download_window_end', i.value)} + /> + diff --git a/wrolpi/common.py b/wrolpi/common.py index 8824c549..e270c5b5 100644 --- a/wrolpi/common.py +++ b/wrolpi/common.py @@ -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'] diff --git a/wrolpi/downloader.py b/wrolpi/downloader.py index 5b8eb467..008bdd44 100644 --- a/wrolpi/downloader.py +++ b/wrolpi/downloader.py @@ -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: diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index dba9880f..b4531206 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -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) diff --git a/wrolpi/schema.py b/wrolpi/schema.py index a08dd42d..8eae841d 100644 --- a/wrolpi/schema.py +++ b/wrolpi/schema.py @@ -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 diff --git a/wrolpi/test/test_downloader.py b/wrolpi/test/test_downloader.py index 11ab9677..d1b27562 100644 --- a/wrolpi/test/test_downloader.py +++ b/wrolpi/test/test_downloader.py @@ -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