diff --git a/docs/docs/documentation/getting-started/installation/backend-config.md b/docs/docs/documentation/getting-started/installation/backend-config.md
index 15e31e43649e..11490e62d94c 100644
--- a/docs/docs/documentation/getting-started/installation/backend-config.md
+++ b/docs/docs/documentation/getting-started/installation/backend-config.md
@@ -4,20 +4,20 @@
### General
-| Variables | Default | Description |
-| ----------------------------- | :-------------------: | ----------------------------------------------------------------------------------- |
-| PUID | 911 | UserID permissions between host OS and container |
-| PGID | 911 | GroupID permissions between host OS and container |
-| DEFAULT_GROUP | Home | The default group for users |
-| BASE_URL | http://localhost:8080 | Used for Notifications |
-| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid |
-| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
-| API_DOCS | True | Turns on/off access to the API documentation locally. |
-| TZ | UTC | Must be set to get correct date/time on the server |
-| ALLOW_SIGNUP\* | false | Allow user sign-up without token |
-| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
-| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug, trace) |
-| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run the daily tasks. |
+| Variables | Default | Description |
+| ----------------------------- | :-------------------: | --------------------------------------------------------------------------------------------------------- |
+| PUID | 911 | UserID permissions between host OS and container |
+| PGID | 911 | GroupID permissions between host OS and container |
+| DEFAULT_GROUP | Home | The default group for users |
+| BASE_URL | http://localhost:8080 | Used for Notifications |
+| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid |
+| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
+| API_DOCS | True | Turns on/off access to the API documentation locally |
+| TZ | UTC | Must be set to get correct date/time on the server |
+| ALLOW_SIGNUP\* | false | Allow user sign-up without token |
+| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
+| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug, trace) |
+| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
\* Starting in v1.4.0 this was changed to default to `false` as part of a security review of the application.
diff --git a/mealie/core/settings/settings.py b/mealie/core/settings/settings.py
index eadd0c4014d7..1eae6a3a19c1 100644
--- a/mealie/core/settings/settings.py
+++ b/mealie/core/settings/settings.py
@@ -1,6 +1,10 @@
+import logging
import secrets
+from datetime import datetime, timezone
from pathlib import Path
+from typing import NamedTuple
+from dateutil.tz import tzlocal
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -9,6 +13,11 @@ from mealie.core.settings.themes import Theme
from .db_providers import AbstractDBProvider, db_provider_factory
+class ScheduleTime(NamedTuple):
+ hour: int
+ minute: int
+
+
def determine_secrets(data_dir: Path, production: bool) -> str:
if not production:
return "shh-secret-test-key"
@@ -58,6 +67,44 @@ class AppSettings(BaseSettings):
ALLOW_SIGNUP: bool = False
DAILY_SCHEDULE_TIME: str = "23:45"
+ """Local server time, in HH:MM format. See `DAILY_SCHEDULE_TIME_UTC` for the parsed UTC equivalent"""
+
+ _logger: logging.Logger | None = None
+
+ @property
+ def logger(self) -> logging.Logger:
+ if self._logger is None:
+ # lazy load the logger, since get_logger depends on the settings being loaded
+ from mealie.core.root_logger import get_logger
+
+ self._logger = get_logger()
+
+ return self._logger
+
+ @property
+ def DAILY_SCHEDULE_TIME_UTC(self) -> ScheduleTime:
+ """The DAILY_SCHEDULE_TIME in UTC, parsed into hours and minutes"""
+
+ # parse DAILY_SCHEDULE_TIME into hours and minutes
+ try:
+ hour_str, minute_str = self.DAILY_SCHEDULE_TIME.split(":")
+ local_hour = int(hour_str)
+ local_minute = int(minute_str)
+ except ValueError:
+ local_hour = 23
+ local_minute = 45
+ self.logger.exception(
+ f"Unable to parse {self.DAILY_SCHEDULE_TIME=} as HH:MM; defaulting to {local_hour}:{local_minute}"
+ )
+
+ # DAILY_SCHEDULE_TIME is in local time, so we convert it to UTC
+ local_tz = tzlocal()
+ now = datetime.now(local_tz)
+ local_time = now.replace(hour=local_hour, minute=local_minute)
+ utc_time = local_time.astimezone(timezone.utc)
+
+ self.logger.debug(f"Local time: {local_hour}:{local_minute} | UTC time: {utc_time.hour}:{utc_time.minute}")
+ return ScheduleTime(utc_time.hour, utc_time.minute)
# ===============================================
# Security Configuration
diff --git a/mealie/services/scheduler/scheduler_service.py b/mealie/services/scheduler/scheduler_service.py
index ce4fc9510ac9..893912205b7f 100644
--- a/mealie/services/scheduler/scheduler_service.py
+++ b/mealie/services/scheduler/scheduler_service.py
@@ -29,20 +29,12 @@ class SchedulerService:
async def schedule_daily():
now = datetime.now(timezone.utc)
- daily_schedule_time = get_app_settings().DAILY_SCHEDULE_TIME
- logger.debug(
- "Current time is %s and DAILY_SCHEDULE_TIME is %s",
- str(now),
- daily_schedule_time,
- )
- try:
- hour_target, minute_target = _parse_daily_schedule_time(daily_schedule_time)
- except Exception:
- logger.exception(f"Unable to parse {daily_schedule_time=}")
- hour_target = 23
- minute_target = 45
+ daily_schedule_time = get_app_settings().DAILY_SCHEDULE_TIME_UTC
+ logger.debug(f"Current time is {now} and DAILY_SCHEDULE_TIME (in UTC) is {daily_schedule_time}")
- next_schedule = now.replace(hour=hour_target, minute=minute_target, second=0, microsecond=0)
+ next_schedule = now.replace(
+ hour=daily_schedule_time.hour, minute=daily_schedule_time.minute, second=0, microsecond=0
+ )
delta = next_schedule - now
if delta < timedelta(0):
next_schedule = next_schedule + timedelta(days=1)
@@ -61,12 +53,6 @@ async def schedule_daily():
await run_daily()
-def _parse_daily_schedule_time(time):
- hour_target = int(time.split(":")[0])
- minute_target = int(time.split(":")[1])
- return hour_target, minute_target
-
-
def _scheduled_task_wrapper(callable):
try:
callable()