diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json
index b03942b..d88c9c1 100644
--- a/frontend/src/lib/i18n/en.json
+++ b/frontend/src/lib/i18n/en.json
@@ -523,6 +523,9 @@
"memorySource": "Memory source",
"memorySourceAlbums": "Scan tracked albums",
"memorySourceNative": "Immich native memories",
+ "quietHours": "Quiet hours",
+ "quietHoursStart": "Start",
+ "quietHoursEnd": "End",
"test": "Test",
"confirmDelete": "Delete this tracking config?",
"sortNone": "None",
@@ -670,6 +673,8 @@
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
"cacheTtl": "Media Cache TTL (hours)",
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading",
+ "timezone": "Timezone",
+ "timezoneHint": "IANA timezone (e.g. UTC, Europe/Warsaw, America/New_York). Used to interpret HH:MM fields like quiet hours.",
"locales": "Template Languages",
"supportedLocales": "Supported Locales",
"supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)",
@@ -680,6 +685,7 @@
"scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.",
"memoryMode": "\"On This Day\" — sends photos taken on this date in previous years. Nostalgic flashbacks.",
"memorySource": "Albums: scans tracked albums for date-matching assets. Native: uses Immich's built-in memories (covers entire library, optionally filtered by tracked albums).",
+ "quietHours": "Suppress all notifications during this HH:MM window (interpreted in the app timezone). Overnight windows like 22:00–07:00 are supported.",
"favoritesOnly": "Only include assets marked as favorites.",
"maxAssets": "Maximum number of asset details to include in a single notification message.",
"periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.",
diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json
index 4e1120e..8ecdaf6 100644
--- a/frontend/src/lib/i18n/ru.json
+++ b/frontend/src/lib/i18n/ru.json
@@ -523,6 +523,9 @@
"memorySource": "Источник воспоминаний",
"memorySourceAlbums": "Сканировать альбомы",
"memorySourceNative": "Встроенные воспоминания Immich",
+ "quietHours": "Тихие часы",
+ "quietHoursStart": "Начало",
+ "quietHoursEnd": "Конец",
"test": "Тест",
"confirmDelete": "Удалить эту конфигурацию отслеживания?",
"sortNone": "Нет",
@@ -670,6 +673,8 @@
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
"cacheTtl": "TTL кэша медиа (часы)",
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
+ "timezone": "Часовой пояс",
+ "timezoneHint": "Часовой пояс IANA (например UTC, Europe/Warsaw, America/New_York). Используется для интерпретации полей HH:MM, таких как тихие часы.",
"locales": "Языки шаблонов",
"supportedLocales": "Поддерживаемые локали",
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)",
@@ -680,6 +685,7 @@
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
"memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.",
"memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).",
+ "quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:00–07:00.",
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
diff --git a/frontend/src/lib/providers/immich.ts b/frontend/src/lib/providers/immich.ts
index 77532b5..0439f2a 100644
--- a/frontend/src/lib/providers/immich.ts
+++ b/frontend/src/lib/providers/immich.ts
@@ -88,6 +88,14 @@ export const immichDescriptor: ProviderDescriptor = {
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' },
],
},
+ {
+ key: 'quietHours', legend: 'trackingConfig.quietHours', legendHint: 'hints.quietHours',
+ enabledField: 'quiet_hours_enabled', enabledDefault: false,
+ fields: [
+ { key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'number', defaultValue: '22:00' },
+ { key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'number', defaultValue: '07:00' },
+ ],
+ },
],
collectionMeta: {
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index c39817d..b04c314 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -192,6 +192,9 @@ export interface TrackingConfig {
memory_favorite_only: boolean;
memory_asset_type: string;
memory_min_rating: number;
+ quiet_hours_enabled: boolean;
+ quiet_hours_start: string | null;
+ quiet_hours_end: string | null;
created_at: string;
}
diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte
index 50dee25..7908e9b 100644
--- a/frontend/src/routes/settings/+page.svelte
+++ b/frontend/src/routes/settings/+page.svelte
@@ -19,6 +19,7 @@
telegram_webhook_secret: '',
telegram_cache_ttl_hours: '48',
supported_locales: 'en,ru',
+ timezone: 'UTC',
});
onMount(async () => {
@@ -57,6 +58,11 @@
+
+
+
+
diff --git a/frontend/src/routes/tracking-configs/+page.svelte b/frontend/src/routes/tracking-configs/+page.svelte
index e7be25c..d4d9496 100644
--- a/frontend/src/routes/tracking-configs/+page.svelte
+++ b/frontend/src/routes/tracking-configs/+page.svelte
@@ -181,9 +181,12 @@
{:else if field.type === 'grid-select' && field.gridItems}
{:else}
-
{/if}
diff --git a/packages/server/src/notify_bridge_server/api/app_settings.py b/packages/server/src/notify_bridge_server/api/app_settings.py
index ce5cc9f..54704c9 100644
--- a/packages/server/src/notify_bridge_server/api/app_settings.py
+++ b/packages/server/src/notify_bridge_server/api/app_settings.py
@@ -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("")
diff --git a/packages/server/src/notify_bridge_server/api/tracking_configs.py b/packages/server/src/notify_bridge_server/api/tracking_configs.py
index e82aad4..88098a3 100644
--- a/packages/server/src/notify_bridge_server/api/tracking_configs.py
+++ b/packages/server/src/notify_bridge_server/api/tracking_configs.py
@@ -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("")
diff --git a/packages/server/src/notify_bridge_server/api/webhooks.py b/packages/server/src/notify_bridge_server/api/webhooks.py
index 8acff74..1bdab14 100644
--- a/packages/server/src/notify_bridge_server/api/webhooks.py
+++ b/packages/server/src/notify_bridge_server/api/webhooks.py
@@ -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"]
diff --git a/packages/server/src/notify_bridge_server/database/models.py b/packages/server/src/notify_bridge_server/database/models.py
index 26eff69..cac9ad4 100644
--- a/packages/server/src/notify_bridge_server/database/models.py
+++ b/packages/server/src/notify_bridge_server/database/models.py
@@ -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)
diff --git a/packages/server/src/notify_bridge_server/services/dispatch_helpers.py b/packages/server/src/notify_bridge_server/services/dispatch_helpers.py
index 06ee98d..482e11b 100644
--- a/packages/server/src/notify_bridge_server/services/dispatch_helpers.py
+++ b/packages/server/src/notify_bridge_server/services/dispatch_helpers.py
@@ -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
diff --git a/packages/server/src/notify_bridge_server/services/watcher.py b/packages/server/src/notify_bridge_server/services/watcher.py
index eb6b499..c004910 100644
--- a/packages/server/src/notify_bridge_server/services/watcher.py
+++ b/packages/server/src/notify_bridge_server/services/watcher.py
@@ -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