feat(tracking): per-config quiet hours with app-level IANA timezone

Add quiet_hours_enabled/start/end to TrackingConfig (HH:MM strings
interpreted in the app-level timezone AppSetting). The dispatch path
loads the app timezone once per run and passes it through
event_allowed_by_config -> in_quiet_hours, so overnight windows like
22:00-07:00 work correctly in any IANA tz.

Frontend exposes a Timezone field under Settings and a Quiet Hours
section on the Immich tracking-config form with time-picker inputs.
This commit is contained in:
2026-04-22 02:31:48 +03:00
parent 56993d2ca3
commit 6c3dd67c1b
12 changed files with 113 additions and 13 deletions
@@ -22,6 +22,7 @@ _SETTING_KEYS = {
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
"telegram_cache_ttl_hours": None, # no env fallback, default 48
"supported_locales": None, # comma-separated locale codes
"timezone": "NOTIFY_BRIDGE_TIMEZONE", # IANA tz (e.g. "Europe/Warsaw"); empty = UTC
}
_DEFAULTS = {
@@ -29,6 +30,7 @@ _DEFAULTS = {
"telegram_webhook_secret": "",
"telegram_cache_ttl_hours": "48",
"supported_locales": "en,ru",
"timezone": "UTC",
}
@@ -50,6 +52,7 @@ class SettingsUpdate(BaseModel):
telegram_webhook_secret: str | None = None
telegram_cache_ttl_hours: str | None = None
supported_locales: str | None = None
timezone: str | None = None
@router.get("")
@@ -54,6 +54,9 @@ class TrackingConfigCreate(BaseModel):
memory_favorite_only: bool = False
memory_asset_type: str = "all"
memory_min_rating: int = 0
quiet_hours_enabled: bool = False
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
class TrackingConfigUpdate(BaseModel):
@@ -93,6 +96,9 @@ class TrackingConfigUpdate(BaseModel):
memory_favorite_only: bool | None = None
memory_asset_type: str | None = None
memory_min_rating: int | None = None
quiet_hours_enabled: bool | None = None
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
@router.get("")
@@ -27,7 +27,11 @@ from ..database.models import (
ServiceProvider,
WebhookPayloadLog,
)
from ..services.dispatch_helpers import event_allowed_by_config, load_link_data
from ..services.dispatch_helpers import (
event_allowed_by_config,
get_app_timezone,
load_link_data,
)
_LOGGER = logging.getLogger(__name__)
@@ -144,6 +148,8 @@ async def _dispatch_webhook_event(
if not link_data:
continue
app_tz = await get_app_timezone(session)
# Log event
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
session.add(EventLog(
@@ -164,7 +170,7 @@ async def _dispatch_webhook_event(
# Dispatch to targets
dispatcher = NotificationDispatcher()
target_configs = _build_target_configs(event, link_data, provider_config)
target_configs = _build_target_configs(event, link_data, provider_config, app_tz)
if target_configs:
results = await dispatcher.dispatch(event, target_configs)
for r in results:
@@ -513,12 +519,13 @@ def _build_target_configs(
event: ServiceEvent,
link_data: list[dict[str, Any]],
provider_config: dict[str, Any],
app_tz: str = "UTC",
) -> list[TargetConfig]:
"""Build TargetConfig objects for dispatch, applying tracking config filters."""
target_configs: list[TargetConfig] = []
for ld in link_data:
tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc):
if tc and not event_allowed_by_config(event, tc, app_tz):
continue
tmpl = ld["template_config"]
@@ -204,6 +204,13 @@ class TrackingConfig(SQLModel, table=True):
memory_asset_type: str = Field(default="all")
memory_min_rating: int = Field(default=0)
# Quiet hours — HH:MM strings interpreted in the app-level timezone
# (AppSetting "timezone"). Gated by quiet_hours_enabled so an empty window
# still represents "explicitly disabled" vs "not yet configured".
quiet_hours_enabled: bool = Field(default=False)
quiet_hours_start: str | None = Field(default=None)
quiet_hours_end: str | None = Field(default=None)
created_at: datetime = Field(default_factory=_utcnow)
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging
from datetime import datetime, time, timezone
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -29,12 +30,32 @@ from ..database.models import (
_LOGGER = logging.getLogger(__name__)
def in_quiet_hours(start: str | None, end: str | None) -> bool:
"""Check if the current UTC time is within the quiet hours window."""
def _resolve_zoneinfo(tz_name: str | None) -> ZoneInfo:
"""Resolve an IANA tz string to a ZoneInfo, falling back to UTC on any error."""
if not tz_name:
return ZoneInfo("UTC")
try:
return ZoneInfo(tz_name)
except (ZoneInfoNotFoundError, ValueError):
_LOGGER.warning("Unknown timezone %r; falling back to UTC", tz_name)
return ZoneInfo("UTC")
def in_quiet_hours(
start: str | None,
end: str | None,
tz_name: str | None = "UTC",
) -> bool:
"""Check if the current time (in the given timezone) is within the quiet window.
HH:MM strings are interpreted in the supplied timezone. If either bound is
missing, quiet hours are disabled.
"""
if not start or not end:
return False
try:
now = datetime.now(timezone.utc).time()
tz = _resolve_zoneinfo(tz_name)
now = datetime.now(timezone.utc).astimezone(tz).time()
t_start = time.fromisoformat(start)
t_end = time.fromisoformat(end)
if t_start <= t_end:
@@ -46,8 +67,25 @@ def in_quiet_hours(start: str | None, end: str | None) -> bool:
return False
def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
"""Check if an event type is allowed by the tracking config's flags."""
async def get_app_timezone(session: AsyncSession) -> str:
"""Load the app-level timezone from AppSetting (falls back to UTC)."""
from ..api.app_settings import get_setting
value = await get_setting(session, "timezone")
return value or "UTC"
def event_allowed_by_config(
event: ServiceEvent,
tc: TrackingConfig,
tz_name: str | None = "UTC",
) -> bool:
"""Check if an event is allowed by the tracking config's flags + quiet hours."""
# Quiet hours gate every event type when enabled.
if tc.quiet_hours_enabled and in_quiet_hours(
tc.quiet_hours_start, tc.quiet_hours_end, tz_name
):
return False
event_type = event.event_type.value
flag_map = {
# Immich events
@@ -21,7 +21,11 @@ from ..database.models import (
NotificationTrackerState,
ServiceProvider,
)
from .dispatch_helpers import event_allowed_by_config, load_link_data
from .dispatch_helpers import (
event_allowed_by_config,
get_app_timezone,
load_link_data,
)
_LOGGER = logging.getLogger(__name__)
@@ -85,7 +89,10 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
}
# Load tracker-target links
link_data = await load_link_data(session, tracker_id, check_quiet_hours=True)
link_data = await load_link_data(session, tracker_id)
# Load app-level timezone for quiet-hours evaluation.
app_tz = await get_app_timezone(session)
# Snapshot the data we need
provider_type = provider.type
@@ -236,7 +243,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
for ld in link_data:
# Apply per-link event filtering from tracking config
tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc):
if tc and not event_allowed_by_config(event, tc, app_tz):
_LOGGER.info(" Skipped by tracking config filter")
continue