Adding timezone to WROLPi config to schedule download window.

This commit is contained in:
Roland 2026-03-13 15:03:13 -06:00
parent c2bab03f42
commit c2c5d237d7
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
8 changed files with 162 additions and 3 deletions

View file

@ -320,6 +320,7 @@ export async function getSettings() {
tags_directory: content.tags_directory,
throttle_on_startup: content.throttle_on_startup,
throttle_status: content.throttle_status,
timezone: content.timezone,
videos_destination: content.videos_destination,
wrol_mode: content.wrol_mode,
zims_destination: content.zims_destination,

View file

@ -3,7 +3,17 @@ import {SettingsContext, StatusContext} from "../../contexts/contexts";
import {checkUpgrade, postRestart, postShutdown, saveSettings as saveSettingsApi, triggerUpgrade} from "../../api";
import {getServices, startService, stopService} from "../../api/controller";
import {Button, Divider, Form, Header, Loader, Modal, Segment} from "../Theme";
import {ButtonGroup, Container, Dimmer, Dropdown, GridColumn, GridRow, Icon, Input} from "semantic-ui-react";
import {
ButtonGroup,
Container,
Dimmer,
Dropdown,
FormDropdown,
GridColumn,
GridRow,
Icon,
Input
} from "semantic-ui-react";
import {
APIButton,
ErrorMessage,
@ -301,6 +311,7 @@ export function SettingsPage() {
// Get current settings from the API.
const {settings, saveSettings, fetchSettings} = React.useContext(SettingsContext);
const {status} = React.useContext(StatusContext);
// Used to track changes to the settings form between saves/loads.
const [state, setState] = React.useState({});
@ -445,6 +456,7 @@ export function SettingsPage() {
download_wait: settings.download_wait,
download_window_start: settings.download_window_start || '',
download_window_end: settings.download_window_end || '',
timezone: settings.timezone || '',
hotspot_device: settings.hotspot_device,
hotspot_on_startup: settings.hotspot_on_startup,
hotspot_password: settings.hotspot_password,
@ -492,6 +504,10 @@ export function SettingsPage() {
<Icon name='qrcode' size='big'/>
</Button>;
const timezoneOptions = Intl.supportedValuesOf('timeZone').map(tz => (
{key: tz, value: tz, text: tz}
));
const navColorOptions = Object.keys(semanticUIColorMap).map(i => {
return {key: i, value: i, text: i.charAt(0).toUpperCase() + i.slice(1)}
});
@ -709,6 +725,25 @@ export function SettingsPage() {
/>
</Form.Group>
<Form.Field width={3}>
<label><b>Timezone</b>
<InfoPopup
content='IANA timezone (e.g. America/Denver). Leave empty to use system timezone.'/>
</label>
<FormDropdown
placeholder='System default'
options={timezoneOptions}
value={state.timezone || ''}
disabled={disabled}
search
selection
clearable
onChange={(e, {value}) => setState({...state, timezone: value || ''})}
/>
</Form.Field>
{status && status.local_time &&
<p>Current local time: <b>{status.local_time.slice(0, 16).replace('T', ' ')}</b></p>}
<Divider/>
<Header as='h3'>Special Directories</Header>

View file

@ -805,6 +805,7 @@ class WROLPiConfigValidator:
save_ffprobe_json: bool = None
tags_directory: bool = None
throttle_on_startup: bool = None
timezone: str = None
version: int = None
videos_destination: str = None
wrol_mode: bool = None
@ -949,6 +950,7 @@ class WROLPiConfig(ConfigFile):
save_ffprobe_json=True,
tags_directory=True,
throttle_on_startup=False,
timezone=None,
version=0,
videos_destination='videos/%(channel_tag)s/%(channel_name)s',
wrol_mode=False,
@ -1184,6 +1186,14 @@ class WROLPiConfig(ConfigFile):
def tags_directory(self, value: bool):
self.update({'tags_directory': value})
@property
def timezone(self) -> str:
return self._config.get('timezone')
@timezone.setter
def timezone(self, value: str):
self.update({'timezone': value})
WROLPI_CONFIG: WROLPiConfig = WROLPiConfig()
TEST_WROLPI_CONFIG: WROLPiConfig = None

View file

@ -18,6 +18,7 @@ from http import HTTPStatus
from itertools import filterfalse
from typing import List, Dict, Generator, Iterable, Coroutine, Any
from typing import Tuple, Optional
from zoneinfo import ZoneInfo
from urllib.parse import urlparse
import feedparser
@ -597,7 +598,12 @@ class DownloadManager:
@staticmethod
def _get_local_time():
"""Return the current local time, converting from UTC."""
"""Return the current local time, using configured timezone or system default."""
from wrolpi.common import get_wrolpi_config
config = get_wrolpi_config()
if config.timezone:
tz = ZoneInfo(config.timezone)
return now().astimezone(tz).time()
return now().astimezone().time()
def is_within_download_window(self) -> bool:

View file

@ -2,6 +2,7 @@ import asyncio
import pathlib
import re
from http import HTTPStatus
from zoneinfo import ZoneInfo
import vininfo.exceptions
from sanic import response, Blueprint, __version__ as sanic_version
@ -178,6 +179,7 @@ def get_settings(_: Request):
'wrol_mode': wrolpi_config.wrol_mode,
'zims_destination': wrolpi_config.zims_destination,
'save_ffprobe_json': wrolpi_config.save_ffprobe_json,
'timezone': wrolpi_config.timezone,
}
return json_response(settings)
@ -232,6 +234,16 @@ async def update_settings(_: Request, body: schema.SettingsRequest):
elif not body.zims_destination:
new_config['zims_destination'] = wrolpi_config.default_config['zims_destination']
# Validate timezone if provided; empty string clears it.
if body.timezone is not None:
if body.timezone == '':
new_config['timezone'] = None
else:
try:
ZoneInfo(body.timezone)
except (KeyError, Exception):
raise InvalidConfig(f'Invalid timezone: {body.timezone}')
# 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)
@ -488,6 +500,13 @@ async def get_status(request: Request):
if i.startswith('Sanic-Server')
}
wrolpi_config = get_wrolpi_config()
if wrolpi_config.timezone:
tz = ZoneInfo(wrolpi_config.timezone)
local_time = now().astimezone(tz).isoformat()
else:
local_time = now().astimezone().isoformat()
ret = dict(
dockerized=DOCKERIZED,
downloads=downloads,
@ -496,6 +515,7 @@ async def get_status(request: Request):
is_rpi4=IS_RPI4,
is_rpi5=IS_RPI5,
is_rpi=IS_RPI,
local_time=local_time,
sanic_workers=sanic_workers,
version=__version__,
wrol_mode=wrol_mode_enabled(),

View file

@ -83,6 +83,7 @@ class SettingsRequest:
wrol_mode: Optional[bool] = None
zims_destination: Optional[str] = None
save_ffprobe_json: Optional[bool] = None
timezone: Optional[str] = None
def __post_init__(self):
self.nav_color = self.nav_color.lower() if self.nav_color else None

View file

@ -2,7 +2,7 @@ import asyncio
import json
import pathlib
from abc import ABC
from datetime import datetime, time
from datetime import datetime, time, timezone, timedelta
from http import HTTPStatus
from itertools import zip_longest
from unittest import mock
@ -1175,3 +1175,38 @@ def test_is_within_download_window_uses_local_time(test_download_manager):
with mock.patch.object(type(test_download_manager), '_get_local_time',
return_value=time(12, 0)):
assert test_download_manager.is_within_download_window() is True
def test_get_local_time_with_configured_timezone(test_download_manager):
"""When config.timezone is set, _get_local_time() converts to that timezone."""
from zoneinfo import ZoneInfo
config = get_wrolpi_config()
config.timezone = 'America/Denver'
# Mock now() to return a known UTC time: 2024-01-15 18:00:00 UTC
# Denver is UTC-7 in January (MST), so local time should be 11:00:00.
mock_utc = datetime(2024, 1, 15, 18, 0, 0, tzinfo=timezone.utc)
with mock.patch('wrolpi.downloader.now', return_value=mock_utc):
result = test_download_manager._get_local_time()
assert result == time(11, 0, 0)
# Summer time: 2024-07-15 18:00:00 UTC
# Denver is UTC-6 in July (MDT), so local time should be 12:00:00.
mock_utc_summer = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc)
with mock.patch('wrolpi.downloader.now', return_value=mock_utc_summer):
result = test_download_manager._get_local_time()
assert result == time(12, 0, 0)
def test_get_local_time_without_configured_timezone(test_download_manager):
"""When config.timezone is None, _get_local_time() falls back to system timezone."""
config = get_wrolpi_config()
config.timezone = None
mock_utc = datetime(2024, 1, 15, 18, 0, 0, tzinfo=timezone.utc)
with mock.patch('wrolpi.downloader.now', return_value=mock_utc):
result = test_download_manager._get_local_time()
# Should return the system's local time conversion.
expected = mock_utc.astimezone().time()
assert result == expected

View file

@ -355,6 +355,21 @@ async def test_get_status(async_client, test_session):
assert isinstance(response.json.get('memory_stats'), dict), 'memory_stats should be a dict'
assert isinstance(response.json.get('flags'), dict), 'flags should be a dict'
assert isinstance(response.json.get('sanic_workers'), dict), 'Sanic worker status should be a dict'
assert isinstance(response.json.get('local_time'), str), 'local_time should be a str'
@pytest.mark.asyncio
async def test_status_local_time_with_timezone(async_client, test_session, test_wrolpi_config):
"""Status endpoint includes local_time using configured timezone."""
from wrolpi.common import get_wrolpi_config
config = get_wrolpi_config()
config.timezone = 'America/Denver'
request, response = await async_client.get('/api/status')
assert response.status_code == HTTPStatus.OK
local_time = response.json['local_time']
# ISO 8601 with timezone offset for Denver (UTC-7 or UTC-6).
assert '+' in local_time or '-' in local_time[10:], 'local_time should include timezone offset'
@pytest.mark.asyncio
@ -805,6 +820,42 @@ async def test_settings_save_ffprobe_json(async_client, test_wrolpi_config):
assert config.save_ffprobe_json is True
@pytest.mark.asyncio
async def test_settings_timezone(async_client, test_wrolpi_config):
"""Maintainer can set and clear the timezone setting."""
config = get_wrolpi_config()
# Default is None.
assert config.timezone is None
# GET returns the setting.
request, response = await async_client.get('/api/settings')
assert response.status_code == HTTPStatus.OK
assert response.json['timezone'] is None
# Setting a valid timezone.
data = {'timezone': 'America/Denver'}
request, response = await async_client.patch('/api/settings', content=json.dumps(data))
assert response.status_code == HTTPStatus.NO_CONTENT
assert config.timezone == 'America/Denver'
# GET reflects the change.
request, response = await async_client.get('/api/settings')
assert response.status_code == HTTPStatus.OK
assert response.json['timezone'] == 'America/Denver'
# Empty string clears the timezone.
data = {'timezone': ''}
request, response = await async_client.patch('/api/settings', content=json.dumps(data))
assert response.status_code == HTTPStatus.NO_CONTENT
assert config.timezone is None
# Invalid timezone is rejected.
data = {'timezone': 'Not/A/Timezone'}
request, response = await async_client.patch('/api/settings', content=json.dumps(data))
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_search_other_estimates(async_client, test_session, channel_factory, tag_factory):
# Can search without Tags or Channels.