Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f880daa0c | |||
| 1024085cdd | |||
| 5604c733d1 | |||
| 3b7808aa9c |
+14
-22
@@ -1,36 +1,28 @@
|
||||
# v0.3.0 (2026-04-22)
|
||||
# v0.3.2 (2026-04-22)
|
||||
|
||||
Major polling perf overhaul for large Immich libraries plus a UX fix for
|
||||
slow bot commands. Combined impact on idle albums: per-tick cost drops
|
||||
from ~150 MB fetched to a few hundred bytes; active albums now fetch
|
||||
O(changes) instead of O(library). Tested against a ~200k-asset library.
|
||||
Scheduler now honors the app-level timezone. Before this, a cron expression
|
||||
like `0 9 * * *` was firing at 09:00 in the server's host-local tz — not
|
||||
at 09:00 in the timezone the admin configured under Settings — because
|
||||
`CronTrigger.from_crontab` was constructed without a tz. Same fix extends
|
||||
to scheduler-provider template rendering so `{{ current_date }}` / `{{ current_time }}`
|
||||
match the configured tz, and scheduled firings now show up in the dashboard
|
||||
event feed with context.
|
||||
|
||||
**Schema change:** adds a `meta_fingerprint` JSON column to
|
||||
`notification_tracker_state` — applied automatically by the startup
|
||||
migration, no manual step required.
|
||||
## Bug Fixes
|
||||
|
||||
## Performance
|
||||
|
||||
- **Skip full album fetch on idle ticks** — new `ImmichAlbumMeta` + `get_album_meta()` probe using `?withoutAssets=true` as a cheap change-detection fingerprint. When the fingerprint matches and no pending assets are outstanding, `poll()` short-circuits and does no asset fetch at all. ([fe38d20](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fe38d20))
|
||||
- **Delta-fetch active albums** — when the fingerprint changes, poll with `updatedAfter` instead of refetching the whole album; falls back to a full fetch only on count decrease or mixed add+remove that delta can't reconcile. ([fe38d20](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fe38d20))
|
||||
- **Parallel meta probes** — `asyncio.gather` over album meta probes so a 20-album tracker pays one round-trip of latency instead of 20. ([fe38d20](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fe38d20))
|
||||
- **Tick-scoped shared-links cache** — new `get_all_shared_links_by_album()` coalesces to one `/api/shared-links` request per tick instead of one per changed album. ([fe38d20](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fe38d20))
|
||||
- **Module-level users cache** — 1 h TTL, sha256-keyed, shared across providers that target the same Immich server. ([fe38d20](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fe38d20))
|
||||
- **Skip `asset_ids` DB rewrite on idle ticks** — watcher no longer rewrites the (potentially ~8 MB for huge albums) JSON column when the fingerprint didn't change. ([fe38d20](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fe38d20))
|
||||
- **Adaptive polling** — after 10 empty ticks the scheduler skips 1-in-2, after 30 empty ticks skips 1-in-4; resets on the first detected change or any schedule edit. ([fe38d20](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fe38d20))
|
||||
- **APScheduler jitter** — `interval/4`, capped at 30 s, to smooth thundering-herd bursts when many trackers share the same `scan_interval`. ([fe38d20](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fe38d20))
|
||||
- **Event payload cap** — 50 added / 200 removed assets per event so a bulk import can't explode a Jinja template or exceed Telegram message limits. ([fe38d20](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fe38d20))
|
||||
- **Cron triggers honor app timezone** — all tracker and action cron triggers are now built with the configured app tz; `CronTrigger` freezes its tz at construction, so the `PUT /settings` endpoint rebuilds existing cron jobs when the timezone changes. Scheduled messages that were silently firing at host-local time will fire at the intended time after upgrade. ([1024085](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1024085))
|
||||
- **Scheduler template context renders in the app tz** — `current_date`, `current_time`, `current_datetime`, `current_weekday` in scheduler-provider templates are now formatted in the configured timezone instead of UTC/host-local. Custom templates that built date strings in the wrong tz now render correctly. ([1024085](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1024085))
|
||||
|
||||
## Features
|
||||
|
||||
- **Chat-action hint stays alive during slow command fetches** — Telegram chat actions expire after ~5 s, so slow bot commands (`/latest`, `/random`, `/favorites`, `/memory`, `/search`, `/find`, `/person`, `/place`, `/summary`) previously showed a hint that vanished long before the media arrived and users saw nothing happening. New `telegram_chat_action` async context manager starts a keep-alive task that re-posts the action every 4 s until it exits; `classify_command_chat_action` maps each command to the right action (`upload_photo` for media-returning commands, `typing` for `/summary`, none for fast DB-only commands like `/status` / `/events`). Wired into both the webhook and long-poll paths. ([69711bb](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/69711bb))
|
||||
- **New `timezone` template variable** — scheduler-provider templates can reference `{{ timezone }}` to display the active IANA tz alongside a date/time. Added across the context builder, variable catalog, sample context, and runtime validator (per the project's 6-file sync rule for template vars). ([1024085](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1024085))
|
||||
- **`scheduled_message` events surface in the dashboard feed** — `EventLog` entries for scheduled firings now carry `schedule_type`, `cron_expression` / `interval_seconds`, `timezone`, and `fire_count`; the dashboard renders them with a dedicated label, icon, and colour so operators can see at a glance when scheduled messages actually fired. ([1024085](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1024085))
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits</summary>
|
||||
|
||||
- [69711bb](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/69711bb) — feat(commands): keep chat-action hint alive during slow command fetches *(alexei.dolgolyov)*
|
||||
- [fe38d20](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/fe38d20) — perf(immich): skip full album fetch on idle ticks; delta-fetch for active ones *(alexei.dolgolyov)*
|
||||
- [1024085](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1024085) — fix(scheduler): honor app timezone for cron triggers and log scheduled events *(alexei.dolgolyov)*
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"collectionRenamed": "collection renamed",
|
||||
"collectionDeleted": "collection deleted",
|
||||
"sharingChanged": "sharing changed",
|
||||
"scheduledMessage": "scheduled message",
|
||||
"actionSuccess": "action run",
|
||||
"actionPartial": "action partial",
|
||||
"actionFailed": "action failed",
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"collectionRenamed": "альбом переименован",
|
||||
"collectionDeleted": "альбом удалён",
|
||||
"sharingChanged": "изменение доступа",
|
||||
"scheduledMessage": "запланированное сообщение",
|
||||
"actionSuccess": "действие выполнено",
|
||||
"actionPartial": "действие частично",
|
||||
"actionFailed": "действие провалено",
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
collection_renamed: 'dashboard.collectionRenamed',
|
||||
collection_deleted: 'dashboard.collectionDeleted',
|
||||
sharing_changed: 'dashboard.sharingChanged',
|
||||
scheduled_message: 'dashboard.scheduledMessage',
|
||||
action_success: 'dashboard.actionSuccess',
|
||||
action_partial: 'dashboard.actionPartial',
|
||||
action_failed: 'dashboard.actionFailed',
|
||||
@@ -231,11 +232,13 @@
|
||||
const eventIcons: Record<string, string> = {
|
||||
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
|
||||
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
|
||||
scheduled_message: 'mdiCalendarClock',
|
||||
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
|
||||
};
|
||||
const eventColors: Record<string, string> = {
|
||||
assets_added: '#059669', assets_removed: '#ef4444',
|
||||
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
|
||||
scheduled_message: '#8b5cf6',
|
||||
action_success: '#0d9488', action_partial: '#f59e0b', action_failed: '#dc2626',
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-core"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -177,7 +177,9 @@ class ImmichActionExecutor(ActionExecutor):
|
||||
needs_thumbnail = album_id in album_created_now
|
||||
|
||||
if album_id and album_id != "__dry_run_new__":
|
||||
album = await self._client.get_album(album_id)
|
||||
# Actions diff the current album state to decide what to
|
||||
# add — must observe fresh data, not a cached view.
|
||||
album = await self._client.get_album(album_id, use_cache=False)
|
||||
if album is None and create_if_missing and create_album_name:
|
||||
if not dry_run:
|
||||
created = await self._client.create_album(create_album_name)
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -18,6 +21,51 @@ _LOGGER = logging.getLogger(__name__)
|
||||
MAX_SEARCH_QUERY_LEN = 256
|
||||
MAX_SEARCH_PERSON_IDS = 50
|
||||
|
||||
# Module-level TTL caches for album bodies and shared-link listings. The
|
||||
# Immich ``GET /api/albums/{id}`` response can be tens or hundreds of MB on a
|
||||
# large album, and bot commands like /random, /latest, /memory all refetch
|
||||
# the same album in quick succession. A short TTL makes repeat runs nearly
|
||||
# instant and deduplicates concurrent fetches so a burst of commands issues
|
||||
# one HTTP call instead of N.
|
||||
#
|
||||
# Caches are module-scoped (not instance-scoped) because ``ImmichClient`` is
|
||||
# constructed fresh per request in several places (api/providers.py,
|
||||
# services/action_runner.py, command handlers), so an instance cache would
|
||||
# never survive to serve a second caller. This mirrors ``_users_cache`` in
|
||||
# ``provider.py``.
|
||||
_ALBUM_CACHE_TTL_SECONDS = 60
|
||||
_SHARED_LINKS_CACHE_TTL_SECONDS = 60
|
||||
# Guard rail against runaway memory — a 200k-asset album response can be
|
||||
# ~150 MB, so even modest caps bound the worst case.
|
||||
_ALBUM_CACHE_MAX_ENTRIES = 32
|
||||
_album_cache_lock = asyncio.Lock()
|
||||
# key = (server_digest, album_id); value = (monotonic_ts, raw_api_dict)
|
||||
# Store the raw dict rather than the parsed ``ImmichAlbumData`` so callers
|
||||
# that pass a ``users_cache`` still get owner-name enrichment on cache hits.
|
||||
_album_cache: dict[tuple[str, str], tuple[float, dict[str, Any]]] = {}
|
||||
_shared_links_cache_lock = asyncio.Lock()
|
||||
# key = server_digest; value = (monotonic_ts, {album_id: [SharedLinkInfo, ...]})
|
||||
# The underlying ``/api/shared-links`` endpoint has no per-album filter, so
|
||||
# every call was already paying for the full server-wide list. Caching the
|
||||
# bucketed result once per server turns N per-album calls into one fetch.
|
||||
_shared_links_cache: dict[str, tuple[float, dict[str, list[SharedLinkInfo]]]] = {}
|
||||
|
||||
|
||||
def _server_digest(url: str, api_key: str) -> str:
|
||||
"""Hashed key that avoids putting raw api_key into cache dict keys."""
|
||||
return hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
def invalidate_album_cache() -> None:
|
||||
"""Drop every cached album body. Call after mutations that invalidate
|
||||
the cached view (e.g. integration tests, manual /refresh commands)."""
|
||||
_album_cache.clear()
|
||||
|
||||
|
||||
def invalidate_shared_links_cache() -> None:
|
||||
"""Drop every cached shared-link listing."""
|
||||
_shared_links_cache.clear()
|
||||
|
||||
# User-facing error bodies — Immich responses may leak internal paths,
|
||||
# hostnames, or headers injected by intermediary proxies. These helpers keep
|
||||
# only a short, scrubbed summary; full bodies are logged server-side only.
|
||||
@@ -184,22 +232,30 @@ class ImmichClient:
|
||||
return {}
|
||||
|
||||
async def get_shared_links(self, album_id: str) -> list[SharedLinkInfo]:
|
||||
links: list[SharedLinkInfo] = []
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/shared-links",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
for link in data:
|
||||
album = link.get("album")
|
||||
key = link.get("key")
|
||||
if album and key and album.get("id") == album_id:
|
||||
links.append(SharedLinkInfo.from_api_response(link))
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to fetch shared links: %s", err)
|
||||
return links
|
||||
bucketed = await self._get_shared_links_bucketed()
|
||||
return list(bucketed.get(album_id, []))
|
||||
|
||||
async def _get_shared_links_bucketed(self) -> dict[str, list[SharedLinkInfo]]:
|
||||
"""Return ``{album_id: [SharedLinkInfo, ...]}`` for the server, hitting
|
||||
the module-level TTL cache first. Underlying Immich endpoint has no
|
||||
per-album filter, so one server-wide fetch serves every caller until
|
||||
the TTL elapses.
|
||||
"""
|
||||
digest = _server_digest(self._url, self._api_key)
|
||||
now = time.monotonic()
|
||||
entry = _shared_links_cache.get(digest)
|
||||
if entry is not None and (now - entry[0]) < _SHARED_LINKS_CACHE_TTL_SECONDS:
|
||||
return entry[1]
|
||||
|
||||
async with _shared_links_cache_lock:
|
||||
# Re-check under the lock — another coroutine may have refreshed
|
||||
# while we waited.
|
||||
entry = _shared_links_cache.get(digest)
|
||||
if entry is not None and (time.monotonic() - entry[0]) < _SHARED_LINKS_CACHE_TTL_SECONDS:
|
||||
return entry[1]
|
||||
fresh = await self.get_all_shared_links_by_album()
|
||||
_shared_links_cache[digest] = (time.monotonic(), fresh)
|
||||
return fresh
|
||||
|
||||
async def get_all_shared_links_by_album(self) -> dict[str, list[SharedLinkInfo]]:
|
||||
"""Fetch every shared link on the server, bucketed by album id.
|
||||
@@ -247,7 +303,29 @@ class ImmichClient:
|
||||
self,
|
||||
album_id: str,
|
||||
users_cache: dict[str, str] | None = None,
|
||||
*,
|
||||
use_cache: bool = True,
|
||||
) -> ImmichAlbumData | None:
|
||||
"""Fetch an album by id, optionally serving from the module-level
|
||||
TTL cache. Pass ``use_cache=False`` from paths that must observe the
|
||||
current server state (e.g. the notification poll loop's full-fetch
|
||||
path, where a stale cached entry would delay asset-removal events).
|
||||
Non-cached fetches still populate the cache for subsequent readers.
|
||||
"""
|
||||
cache_key = (_server_digest(self._url, self._api_key), album_id)
|
||||
if use_cache:
|
||||
entry = _album_cache.get(cache_key)
|
||||
if entry is not None and (time.monotonic() - entry[0]) < _ALBUM_CACHE_TTL_SECONDS:
|
||||
# Rehydrate per-call so ``users_cache`` enrichment is applied
|
||||
# with the caller's dict, not whichever one was live when the
|
||||
# cache was populated.
|
||||
return ImmichAlbumData.from_api_response(entry[1], users_cache)
|
||||
|
||||
# Deliberately fetch without holding a lock so concurrent calls for
|
||||
# *different* album_ids (the common case from asyncio.gather in
|
||||
# fetch_albums_with_links) stay parallel. The worst case is a small
|
||||
# duplicate-fetch stampede when two requests miss the same album at
|
||||
# the same instant — acceptable for our scale.
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/albums/{album_id}",
|
||||
@@ -260,10 +338,18 @@ class ImmichClient:
|
||||
f"Error fetching album {album_id}: HTTP {response.status}"
|
||||
)
|
||||
data = await response.json()
|
||||
return ImmichAlbumData.from_api_response(data, users_cache)
|
||||
except aiohttp.ClientError as err:
|
||||
raise ImmichApiError(f"Error communicating with Immich: {err}") from err
|
||||
|
||||
async with _album_cache_lock:
|
||||
# Evict the oldest entry if we're at the cap — simple FIFO is fine
|
||||
# for our access pattern (commands touch a small working set).
|
||||
if len(_album_cache) >= _ALBUM_CACHE_MAX_ENTRIES and cache_key not in _album_cache:
|
||||
oldest = min(_album_cache.items(), key=lambda kv: kv[1][0])[0]
|
||||
_album_cache.pop(oldest, None)
|
||||
_album_cache[cache_key] = (time.monotonic(), data)
|
||||
return ImmichAlbumData.from_api_response(data, users_cache)
|
||||
|
||||
async def get_album_meta(self, album_id: str) -> ImmichAlbumMeta | None:
|
||||
"""Fetch album metadata without the assets array.
|
||||
|
||||
|
||||
@@ -292,7 +292,13 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
# the full-fetch path so removals get detected.
|
||||
|
||||
# Full fetch: first tick, or count-decreased, or delta-unsafe.
|
||||
album = await self._client.get_album(album_id, self._users_cache)
|
||||
# Bypass the module-level album cache — this path runs when we
|
||||
# specifically need the current server state (e.g. to detect
|
||||
# asset removals), so a stale cached entry would silently delay
|
||||
# the event.
|
||||
album = await self._client.get_album(
|
||||
album_id, self._users_cache, use_cache=False,
|
||||
)
|
||||
if album is None:
|
||||
# Album was deleted between meta probe and full fetch — handle
|
||||
# the deletion the same way as above.
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from notify_bridge_core.models.events import EventType, ServiceEvent
|
||||
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
|
||||
@@ -57,6 +58,13 @@ SCHEDULER_VARIABLES: list[TemplateVariableDefinition] = [
|
||||
example="Monday",
|
||||
provider_type=ServiceProviderType.SCHEDULER,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="timezone",
|
||||
type="string",
|
||||
description="IANA timezone used to compute current_date/time",
|
||||
example="Europe/Warsaw",
|
||||
provider_type=ServiceProviderType.SCHEDULER,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="custom_vars",
|
||||
type="dict",
|
||||
@@ -83,7 +91,8 @@ class SchedulerServiceProvider(ServiceProvider):
|
||||
custom_variables: dict[str, str] | None = None,
|
||||
date_format: str = "%d.%m.%Y",
|
||||
time_format: str = "%H:%M",
|
||||
datetime_format: str = "%d.%m.%Y, %H:%M UTC",
|
||||
datetime_format: str = "%d.%m.%Y, %H:%M %Z",
|
||||
timezone_name: str | None = None,
|
||||
) -> None:
|
||||
self._name = name
|
||||
self._tracker_name = tracker_name
|
||||
@@ -91,6 +100,18 @@ class SchedulerServiceProvider(ServiceProvider):
|
||||
self._date_format = date_format
|
||||
self._time_format = time_format
|
||||
self._datetime_format = datetime_format
|
||||
# Resolve a timezone for date/time rendering. Falls back to UTC on
|
||||
# invalid IANA names so a typo in app settings doesn't break polls.
|
||||
tz: ZoneInfo
|
||||
if timezone_name:
|
||||
try:
|
||||
tz = ZoneInfo(timezone_name)
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
_LOGGER.warning("Unknown timezone %r; falling back to UTC", timezone_name)
|
||||
tz = ZoneInfo("UTC")
|
||||
else:
|
||||
tz = ZoneInfo("UTC")
|
||||
self._tz = tz
|
||||
|
||||
async def connect(self) -> bool:
|
||||
return True # virtual provider — always connected
|
||||
@@ -103,7 +124,8 @@ class SchedulerServiceProvider(ServiceProvider):
|
||||
collection_ids: list[str],
|
||||
tracker_state: dict[str, Any],
|
||||
) -> tuple[list[ServiceEvent], dict[str, Any]]:
|
||||
now = datetime.now(timezone.utc)
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
now = now_utc.astimezone(self._tz)
|
||||
# State uses {collection_id: {dict}} convention like other providers
|
||||
sched_state = tracker_state.get("scheduler", {})
|
||||
fire_count = sched_state.get("fire_count", 0) + 1
|
||||
@@ -115,6 +137,7 @@ class SchedulerServiceProvider(ServiceProvider):
|
||||
"current_time": now.strftime(self._time_format),
|
||||
"current_datetime": now.strftime(self._datetime_format),
|
||||
"weekday": _WEEKDAYS[now.weekday()],
|
||||
"timezone": self._tz.key,
|
||||
"custom_vars": dict(self._custom_variables),
|
||||
}
|
||||
# Flatten custom variables at top level for easy template access
|
||||
|
||||
@@ -224,6 +224,7 @@ def build_template_context(
|
||||
ctx.setdefault("current_time", event.extra.get("current_time", ""))
|
||||
ctx.setdefault("current_datetime", event.extra.get("current_datetime", ""))
|
||||
ctx.setdefault("weekday", event.extra.get("weekday", ""))
|
||||
ctx.setdefault("timezone", event.extra.get("timezone", "UTC"))
|
||||
ctx.setdefault("custom_vars", event.extra.get("custom_vars", {}))
|
||||
|
||||
return ctx
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-server"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -94,6 +94,7 @@ async def update_settings(
|
||||
old_base_url = await get_setting(session, "external_url")
|
||||
old_secret = await get_setting(session, "telegram_webhook_secret")
|
||||
old_cache_values = {k: await get_setting(session, k) for k in _CACHE_SETTING_KEYS}
|
||||
old_timezone = await get_setting(session, "timezone")
|
||||
|
||||
for key in _SETTING_KEYS:
|
||||
value = getattr(body, key, None)
|
||||
@@ -128,6 +129,14 @@ async def update_settings(
|
||||
|
||||
new_base_url = await get_setting(session, "external_url")
|
||||
new_secret = await get_setting(session, "telegram_webhook_secret")
|
||||
new_timezone = await get_setting(session, "timezone")
|
||||
|
||||
# Cron triggers freeze their timezone at construction time, so a tz change
|
||||
# has no effect until the jobs are rebuilt — do that here, before we
|
||||
# return success, so the UI reflects the actual schedule immediately.
|
||||
if new_timezone != old_timezone:
|
||||
from ..services.scheduler import reschedule_cron_jobs_for_timezone_change
|
||||
await reschedule_cron_jobs_for_timezone_change()
|
||||
|
||||
# Update webhook secret in the webhook handler module
|
||||
if new_secret != old_secret:
|
||||
|
||||
@@ -242,6 +242,8 @@ async def get_template_variables(
|
||||
"current_date": "Current date (formatted)",
|
||||
"current_time": "Current time (formatted)",
|
||||
"current_datetime": "Current date and time (formatted)",
|
||||
"weekday": "Day of the week (Monday..Sunday)",
|
||||
"timezone": "IANA timezone used for current_date/time",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ _SAMPLE_CONTEXT = {
|
||||
"current_time": "09:00",
|
||||
"current_datetime": "22.03.2026, 09:00 UTC",
|
||||
"weekday": "Monday",
|
||||
"timezone": "UTC",
|
||||
"custom_vars": {"team": "Engineering", "message": "Time for standup!"},
|
||||
"team": "Engineering",
|
||||
"message": "Time for standup!",
|
||||
|
||||
@@ -3,11 +3,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_zoneinfo(tz_name: str | None) -> ZoneInfo:
|
||||
"""Resolve an IANA tz string to a ZoneInfo, falling back to UTC on any error.
|
||||
|
||||
Kept local to avoid importing from api/dispatch layers inside the scheduler
|
||||
module (which is loaded at startup, before the API routers).
|
||||
"""
|
||||
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")
|
||||
|
||||
|
||||
async def _load_app_timezone() -> ZoneInfo:
|
||||
"""Load the admin-configured app timezone from AppSetting (falls back to UTC)."""
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..api.app_settings import get_setting
|
||||
from ..database.engine import get_engine
|
||||
|
||||
async with AsyncSession(get_engine()) as session:
|
||||
tz_name = await get_setting(session, "timezone")
|
||||
return _resolve_zoneinfo(tz_name)
|
||||
|
||||
_scheduler: AsyncIOScheduler | None = None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -293,6 +321,8 @@ async def _load_tracker_jobs() -> None:
|
||||
)
|
||||
provider_types = {p.id: p.type for p in provider_result.all()}
|
||||
|
||||
tz = await _load_app_timezone()
|
||||
|
||||
for tracker in trackers:
|
||||
job_id = f"tracker_{tracker.id}"
|
||||
if scheduler.get_job(job_id):
|
||||
@@ -306,7 +336,7 @@ async def _load_tracker_jobs() -> None:
|
||||
cron_expr = filters.get("cron_expression", "")
|
||||
if cron_expr:
|
||||
try:
|
||||
_add_cron_job(scheduler, job_id, tracker.id, cron_expr, tracker.name)
|
||||
_add_cron_job(scheduler, job_id, tracker.id, cron_expr, tracker.name, tz)
|
||||
continue
|
||||
except Exception as e:
|
||||
_LOGGER.error(
|
||||
@@ -337,10 +367,18 @@ def _add_cron_job(
|
||||
tracker_id: int,
|
||||
cron_expression: str,
|
||||
tracker_name: str,
|
||||
tz: ZoneInfo,
|
||||
) -> None:
|
||||
"""Add a cron-triggered job for a scheduler-type tracker."""
|
||||
"""Add a cron-triggered job for a scheduler-type tracker.
|
||||
|
||||
``tz`` is the user-configured app timezone; without it APScheduler
|
||||
interprets the crontab in the host's local timezone, which surfaces as
|
||||
events firing at the "wrong" wall-clock time for operators in a non-UTC
|
||||
zone (see the companion fix in ``update_settings`` which reschedules on
|
||||
timezone changes).
|
||||
"""
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
trigger = CronTrigger.from_crontab(cron_expression)
|
||||
trigger = CronTrigger.from_crontab(cron_expression, timezone=tz)
|
||||
scheduler.add_job(
|
||||
_poll_tracker,
|
||||
trigger,
|
||||
@@ -349,7 +387,10 @@ def _add_cron_job(
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
)
|
||||
_LOGGER.info("Scheduled tracker %d (%s) with cron: %s", tracker_id, tracker_name, cron_expression)
|
||||
_LOGGER.info(
|
||||
"Scheduled tracker %d (%s) with cron: %s [tz=%s]",
|
||||
tracker_id, tracker_name, cron_expression, tz.key,
|
||||
)
|
||||
|
||||
|
||||
async def schedule_tracker(
|
||||
@@ -371,7 +412,8 @@ async def schedule_tracker(
|
||||
|
||||
if cron_expression:
|
||||
try:
|
||||
_add_cron_job(scheduler, job_id, tracker_id, cron_expression, f"tracker-{tracker_id}")
|
||||
tz = await _load_app_timezone()
|
||||
_add_cron_job(scheduler, job_id, tracker_id, cron_expression, f"tracker-{tracker_id}", tz)
|
||||
return
|
||||
except Exception as e:
|
||||
_LOGGER.error("Invalid cron for tracker %d: %s — using interval", tracker_id, e)
|
||||
@@ -506,6 +548,8 @@ async def _load_action_jobs() -> None:
|
||||
)
|
||||
actions = result.all()
|
||||
|
||||
tz = await _load_app_timezone()
|
||||
|
||||
for action in actions:
|
||||
job_id = f"action_{action.id}"
|
||||
if scheduler.get_job(job_id):
|
||||
@@ -514,7 +558,7 @@ async def _load_action_jobs() -> None:
|
||||
if action.schedule_type == "cron" and action.schedule_cron:
|
||||
try:
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
trigger = CronTrigger.from_crontab(action.schedule_cron)
|
||||
trigger = CronTrigger.from_crontab(action.schedule_cron, timezone=tz)
|
||||
scheduler.add_job(
|
||||
_run_action,
|
||||
trigger,
|
||||
@@ -523,8 +567,8 @@ async def _load_action_jobs() -> None:
|
||||
replace_existing=True,
|
||||
)
|
||||
_LOGGER.info(
|
||||
"Scheduled action %d (%s) with cron: %s",
|
||||
action.id, action.name, action.schedule_cron,
|
||||
"Scheduled action %d (%s) with cron: %s [tz=%s]",
|
||||
action.id, action.name, action.schedule_cron, tz.key,
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
@@ -563,7 +607,8 @@ async def schedule_action(
|
||||
if schedule_type == "cron" and cron_expression:
|
||||
try:
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
trigger = CronTrigger.from_crontab(cron_expression)
|
||||
tz = await _load_app_timezone()
|
||||
trigger = CronTrigger.from_crontab(cron_expression, timezone=tz)
|
||||
scheduler.add_job(
|
||||
_run_action,
|
||||
trigger,
|
||||
@@ -571,7 +616,10 @@ async def schedule_action(
|
||||
args=[action_id],
|
||||
replace_existing=True,
|
||||
)
|
||||
_LOGGER.info("Scheduled action %d with cron: %s", action_id, cron_expression)
|
||||
_LOGGER.info(
|
||||
"Scheduled action %d with cron: %s [tz=%s]",
|
||||
action_id, cron_expression, tz.key,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
_LOGGER.error("Invalid cron for action %d: %s — using interval", action_id, e)
|
||||
@@ -596,6 +644,92 @@ async def unschedule_action(action_id: int) -> None:
|
||||
_LOGGER.info("Unscheduled action %d", action_id)
|
||||
|
||||
|
||||
async def reschedule_cron_jobs_for_timezone_change() -> None:
|
||||
"""Re-add every cron-triggered tracker/action job under the new app timezone.
|
||||
|
||||
Called by the admin settings endpoint after the ``timezone`` AppSetting is
|
||||
updated. APScheduler's ``CronTrigger`` freezes its timezone at construction
|
||||
time, so a timezone change has no effect on jobs already in the scheduler
|
||||
— we have to rebuild those jobs. Interval-triggered jobs are tz-agnostic
|
||||
and are left alone.
|
||||
"""
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import Action, NotificationTracker, ServiceProvider as ServiceProviderModel
|
||||
|
||||
engine = get_engine()
|
||||
scheduler = get_scheduler()
|
||||
tz = await _load_app_timezone()
|
||||
rescheduled = 0
|
||||
|
||||
async with AsyncSession(engine) as session:
|
||||
# Trackers with cron scheduling (scheduler provider + schedule_type=cron).
|
||||
trackers = (await session.exec(
|
||||
select(NotificationTracker).where(NotificationTracker.enabled == True) # noqa: E712
|
||||
)).all()
|
||||
provider_ids = list({t.provider_id for t in trackers})
|
||||
provider_types: dict[int, str] = {}
|
||||
if provider_ids:
|
||||
rows = await session.exec(
|
||||
select(ServiceProviderModel).where(ServiceProviderModel.id.in_(provider_ids))
|
||||
)
|
||||
provider_types = {p.id: p.type for p in rows.all()}
|
||||
|
||||
for tracker in trackers:
|
||||
if provider_types.get(tracker.provider_id) != "scheduler":
|
||||
continue
|
||||
filters = tracker.filters or {}
|
||||
if filters.get("schedule_type") != "cron":
|
||||
continue
|
||||
cron_expr = filters.get("cron_expression", "")
|
||||
if not cron_expr:
|
||||
continue
|
||||
job_id = f"tracker_{tracker.id}"
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.remove_job(job_id)
|
||||
try:
|
||||
_add_cron_job(scheduler, job_id, tracker.id, cron_expr, tracker.name, tz)
|
||||
rescheduled += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
_LOGGER.error(
|
||||
"Failed to re-apply cron for tracker %d on tz change: %s",
|
||||
tracker.id, e,
|
||||
)
|
||||
|
||||
# Actions with cron schedules.
|
||||
actions = (await session.exec(
|
||||
select(Action).where(Action.enabled == True) # noqa: E712
|
||||
)).all()
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
for action in actions:
|
||||
if action.schedule_type != "cron" or not action.schedule_cron:
|
||||
continue
|
||||
job_id = f"action_{action.id}"
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.remove_job(job_id)
|
||||
try:
|
||||
scheduler.add_job(
|
||||
_run_action,
|
||||
CronTrigger.from_crontab(action.schedule_cron, timezone=tz),
|
||||
id=job_id,
|
||||
args=[action.id],
|
||||
replace_existing=True,
|
||||
)
|
||||
rescheduled += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
_LOGGER.error(
|
||||
"Failed to re-apply cron for action %d on tz change: %s",
|
||||
action.id, e,
|
||||
)
|
||||
|
||||
_LOGGER.info(
|
||||
"Rescheduled %d cron job(s) for new app timezone %s", rescheduled, tz.key,
|
||||
)
|
||||
|
||||
|
||||
async def _run_action(action_id: int) -> None:
|
||||
"""Run an action (called by APScheduler)."""
|
||||
from .action_runner import run_action
|
||||
|
||||
@@ -246,6 +246,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
name=provider_name,
|
||||
tracker_name=tracker_name,
|
||||
custom_variables=custom_vars,
|
||||
timezone_name=app_tz,
|
||||
)
|
||||
events, new_state = await sched.poll(collection_ids, state_dict)
|
||||
elif provider_type == "nut":
|
||||
@@ -317,6 +318,26 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
|
||||
for event in events:
|
||||
assets_count = event.added_count or event.removed_count or 0
|
||||
details: dict[str, Any] = {
|
||||
"added_count": event.added_count,
|
||||
"removed_count": event.removed_count,
|
||||
"provider_type": event.provider_type.value,
|
||||
}
|
||||
# Scheduler/periodic events carry the schedule context in ``extra``
|
||||
# (cron expression, interval, timezone, fire count). Surface that
|
||||
# in the event log so the dashboard and audit queries can show
|
||||
# *why* the event fired, not just that it did.
|
||||
if event.event_type.value == "scheduled_message":
|
||||
sched_type = tracker_filters.get("schedule_type", "interval")
|
||||
details["schedule_type"] = sched_type
|
||||
if sched_type == "cron":
|
||||
details["cron_expression"] = tracker_filters.get("cron_expression", "")
|
||||
else:
|
||||
details["interval_seconds"] = tracker.scan_interval
|
||||
details["timezone"] = app_tz
|
||||
fire_count = event.extra.get("fire_count") if event.extra else None
|
||||
if fire_count is not None:
|
||||
details["fire_count"] = fire_count
|
||||
log = EventLog(
|
||||
user_id=tracker.user_id,
|
||||
tracker_id=tracker_id,
|
||||
@@ -327,11 +348,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
collection_id=event.collection_id,
|
||||
collection_name=event.collection_name,
|
||||
assets_count=assets_count,
|
||||
details={
|
||||
"added_count": event.added_count,
|
||||
"removed_count": event.removed_count,
|
||||
"provider_type": event.provider_type.value,
|
||||
},
|
||||
details=details,
|
||||
)
|
||||
session.add(log)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user