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
+6
View File
@@ -523,6 +523,9 @@
"memorySource": "Memory source", "memorySource": "Memory source",
"memorySourceAlbums": "Scan tracked albums", "memorySourceAlbums": "Scan tracked albums",
"memorySourceNative": "Immich native memories", "memorySourceNative": "Immich native memories",
"quietHours": "Quiet hours",
"quietHoursStart": "Start",
"quietHoursEnd": "End",
"test": "Test", "test": "Test",
"confirmDelete": "Delete this tracking config?", "confirmDelete": "Delete this tracking config?",
"sortNone": "None", "sortNone": "None",
@@ -670,6 +673,8 @@
"webhookSecretHint": "Secret token to verify webhook requests from Telegram", "webhookSecretHint": "Secret token to verify webhook requests from Telegram",
"cacheTtl": "Media Cache TTL (hours)", "cacheTtl": "Media Cache TTL (hours)",
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading", "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", "locales": "Template Languages",
"supportedLocales": "Supported Locales", "supportedLocales": "Supported Locales",
"supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)", "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.", "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.", "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).", "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:0007:00 are supported.",
"favoritesOnly": "Only include assets marked as favorites.", "favoritesOnly": "Only include assets marked as favorites.",
"maxAssets": "Maximum number of asset details to include in a single notification message.", "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.", "periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.",
+6
View File
@@ -523,6 +523,9 @@
"memorySource": "Источник воспоминаний", "memorySource": "Источник воспоминаний",
"memorySourceAlbums": "Сканировать альбомы", "memorySourceAlbums": "Сканировать альбомы",
"memorySourceNative": "Встроенные воспоминания Immich", "memorySourceNative": "Встроенные воспоминания Immich",
"quietHours": "Тихие часы",
"quietHoursStart": "Начало",
"quietHoursEnd": "Конец",
"test": "Тест", "test": "Тест",
"confirmDelete": "Удалить эту конфигурацию отслеживания?", "confirmDelete": "Удалить эту конфигурацию отслеживания?",
"sortNone": "Нет", "sortNone": "Нет",
@@ -670,6 +673,8 @@
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram", "webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
"cacheTtl": "TTL кэша медиа (часы)", "cacheTtl": "TTL кэша медиа (часы)",
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой", "cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
"timezone": "Часовой пояс",
"timezoneHint": "Часовой пояс IANA (например UTC, Europe/Warsaw, America/New_York). Используется для интерпретации полей HH:MM, таких как тихие часы.",
"locales": "Языки шаблонов", "locales": "Языки шаблонов",
"supportedLocales": "Поддерживаемые локали", "supportedLocales": "Поддерживаемые локали",
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)", "supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)",
@@ -680,6 +685,7 @@
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.", "scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
"memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.", "memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.",
"memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).", "memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).",
"quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:0007:00.",
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.", "favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
"maxAssets": "Максимальное количество ассетов в одном уведомлении.", "maxAssets": "Максимальное количество ассетов в одном уведомлении.",
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.", "periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
+8
View File
@@ -88,6 +88,14 @@ export const immichDescriptor: ProviderDescriptor = {
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' }, { 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: { collectionMeta: {
+3
View File
@@ -192,6 +192,9 @@ export interface TrackingConfig {
memory_favorite_only: boolean; memory_favorite_only: boolean;
memory_asset_type: string; memory_asset_type: string;
memory_min_rating: number; memory_min_rating: number;
quiet_hours_enabled: boolean;
quiet_hours_start: string | null;
quiet_hours_end: string | null;
created_at: string; created_at: string;
} }
@@ -19,6 +19,7 @@
telegram_webhook_secret: '', telegram_webhook_secret: '',
telegram_cache_ttl_hours: '48', telegram_cache_ttl_hours: '48',
supported_locales: 'en,ru', supported_locales: 'en,ru',
timezone: 'UTC',
}); });
onMount(async () => { onMount(async () => {
@@ -57,6 +58,11 @@
<input bind:value={settings.external_url} placeholder="https://notify.example.com" <input bind:value={settings.external_url} placeholder="https://notify.example.com"
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" /> class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div> </div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
<input bind:value={settings.timezone} placeholder="UTC"
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div>
</div> </div>
</Card> </Card>
@@ -181,9 +181,12 @@
{:else if field.type === 'grid-select' && field.gridItems} {:else if field.type === 'grid-select' && field.gridItems}
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact /> <IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
{:else} {:else}
<input type={field.key.includes('date') ? 'date' : field.key.includes('times') ? 'text' : 'number'} <input type={field.key.includes('date') ? 'date'
: field.key.startsWith('quiet_hours_') ? 'time'
: field.key.includes('times') ? 'text'
: 'number'}
bind:value={form[field.key]} min={field.min} max={field.max} bind:value={form[field.key]} min={field.min} max={field.max}
placeholder={field.key.includes('times') ? String(field.defaultValue ?? '') : ''} placeholder={field.key.includes('times') || field.key.startsWith('quiet_hours_') ? String(field.defaultValue ?? '') : ''}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{/if} {/if}
</div> </div>
@@ -22,6 +22,7 @@ _SETTING_KEYS = {
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET", "telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
"telegram_cache_ttl_hours": None, # no env fallback, default 48 "telegram_cache_ttl_hours": None, # no env fallback, default 48
"supported_locales": None, # comma-separated locale codes "supported_locales": None, # comma-separated locale codes
"timezone": "NOTIFY_BRIDGE_TIMEZONE", # IANA tz (e.g. "Europe/Warsaw"); empty = UTC
} }
_DEFAULTS = { _DEFAULTS = {
@@ -29,6 +30,7 @@ _DEFAULTS = {
"telegram_webhook_secret": "", "telegram_webhook_secret": "",
"telegram_cache_ttl_hours": "48", "telegram_cache_ttl_hours": "48",
"supported_locales": "en,ru", "supported_locales": "en,ru",
"timezone": "UTC",
} }
@@ -50,6 +52,7 @@ class SettingsUpdate(BaseModel):
telegram_webhook_secret: str | None = None telegram_webhook_secret: str | None = None
telegram_cache_ttl_hours: str | None = None telegram_cache_ttl_hours: str | None = None
supported_locales: str | None = None supported_locales: str | None = None
timezone: str | None = None
@router.get("") @router.get("")
@@ -54,6 +54,9 @@ class TrackingConfigCreate(BaseModel):
memory_favorite_only: bool = False memory_favorite_only: bool = False
memory_asset_type: str = "all" memory_asset_type: str = "all"
memory_min_rating: int = 0 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): class TrackingConfigUpdate(BaseModel):
@@ -93,6 +96,9 @@ class TrackingConfigUpdate(BaseModel):
memory_favorite_only: bool | None = None memory_favorite_only: bool | None = None
memory_asset_type: str | None = None memory_asset_type: str | None = None
memory_min_rating: int | 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("") @router.get("")
@@ -27,7 +27,11 @@ from ..database.models import (
ServiceProvider, ServiceProvider,
WebhookPayloadLog, 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__) _LOGGER = logging.getLogger(__name__)
@@ -144,6 +148,8 @@ async def _dispatch_webhook_event(
if not link_data: if not link_data:
continue continue
app_tz = await get_app_timezone(session)
# Log event # Log event
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys} extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
session.add(EventLog( session.add(EventLog(
@@ -164,7 +170,7 @@ async def _dispatch_webhook_event(
# Dispatch to targets # Dispatch to targets
dispatcher = NotificationDispatcher() 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: if target_configs:
results = await dispatcher.dispatch(event, target_configs) results = await dispatcher.dispatch(event, target_configs)
for r in results: for r in results:
@@ -513,12 +519,13 @@ def _build_target_configs(
event: ServiceEvent, event: ServiceEvent,
link_data: list[dict[str, Any]], link_data: list[dict[str, Any]],
provider_config: dict[str, Any], provider_config: dict[str, Any],
app_tz: str = "UTC",
) -> list[TargetConfig]: ) -> list[TargetConfig]:
"""Build TargetConfig objects for dispatch, applying tracking config filters.""" """Build TargetConfig objects for dispatch, applying tracking config filters."""
target_configs: list[TargetConfig] = [] target_configs: list[TargetConfig] = []
for ld in link_data: for ld in link_data:
tc = ld["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):
continue continue
tmpl = ld["template_config"] tmpl = ld["template_config"]
@@ -204,6 +204,13 @@ class TrackingConfig(SQLModel, table=True):
memory_asset_type: str = Field(default="all") memory_asset_type: str = Field(default="all")
memory_min_rating: int = Field(default=0) 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) created_at: datetime = Field(default_factory=_utcnow)
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, time, timezone from datetime import datetime, time, timezone
from typing import Any from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -29,12 +30,32 @@ from ..database.models import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def in_quiet_hours(start: str | None, end: str | None) -> bool: def _resolve_zoneinfo(tz_name: str | None) -> ZoneInfo:
"""Check if the current UTC time is within the quiet hours window.""" """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: if not start or not end:
return False return False
try: 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_start = time.fromisoformat(start)
t_end = time.fromisoformat(end) t_end = time.fromisoformat(end)
if t_start <= t_end: if t_start <= t_end:
@@ -46,8 +67,25 @@ def in_quiet_hours(start: str | None, end: str | None) -> bool:
return False return False
def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool: async def get_app_timezone(session: AsyncSession) -> str:
"""Check if an event type is allowed by the tracking config's flags.""" """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 event_type = event.event_type.value
flag_map = { flag_map = {
# Immich events # Immich events
@@ -21,7 +21,11 @@ from ..database.models import (
NotificationTrackerState, NotificationTrackerState,
ServiceProvider, 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__) _LOGGER = logging.getLogger(__name__)
@@ -85,7 +89,10 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
} }
# Load tracker-target links # 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 # Snapshot the data we need
provider_type = provider.type provider_type = provider.type
@@ -236,7 +243,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
for ld in link_data: for ld in link_data:
# Apply per-link event filtering from tracking config # Apply per-link event filtering from tracking config
tc = ld["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") _LOGGER.info(" Skipped by tracking config filter")
continue continue