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', ' ')}

} +
Special Directories
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.