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