diff --git a/app/src/api.js b/app/src/api.js
index 4986b350..1f7bea44 100644
--- a/app/src/api.js
+++ b/app/src/api.js
@@ -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,
diff --git a/app/src/components/admin/Settings.js b/app/src/components/admin/Settings.js
index 35ef48f5..054c0ec1 100644
--- a/app/src/components/admin/Settings.js
+++ b/app/src/components/admin/Settings.js
@@ -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() {
;
+ 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() {
/>
+
+
+ setState({...state, timezone: value || ''})}
+ />
+
+ {status && status.local_time &&
+ Current local time: {status.local_time.slice(0, 16).replace('T', ' ')}
}
+
diff --git a/wrolpi/common.py b/wrolpi/common.py
index debe6f47..a2a04073 100644
--- a/wrolpi/common.py
+++ b/wrolpi/common.py
@@ -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
diff --git a/wrolpi/downloader.py b/wrolpi/downloader.py
index 9f644738..0f706b46 100644
--- a/wrolpi/downloader.py
+++ b/wrolpi/downloader.py
@@ -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:
diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py
index b4531206..185e5ce5 100644
--- a/wrolpi/root_api.py
+++ b/wrolpi/root_api.py
@@ -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(),
diff --git a/wrolpi/schema.py b/wrolpi/schema.py
index 8eae841d..0b485a3c 100644
--- a/wrolpi/schema.py
+++ b/wrolpi/schema.py
@@ -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
diff --git a/wrolpi/test/test_downloader.py b/wrolpi/test/test_downloader.py
index fdf9d286..614b1875 100644
--- a/wrolpi/test/test_downloader.py
+++ b/wrolpi/test/test_downloader.py
@@ -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
diff --git a/wrolpi/test/test_root_api.py b/wrolpi/test/test_root_api.py
index 6718d865..1270b32b 100644
--- a/wrolpi/test/test_root_api.py
+++ b/wrolpi/test/test_root_api.py
@@ -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.