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}
/>
-
+
+
+
+
-
+
+
-
-
- 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