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
|
Scheduler now honors the app-level timezone. Before this, a cron expression
|
||||||
slow bot commands. Combined impact on idle albums: per-tick cost drops
|
like `0 9 * * *` was firing at 09:00 in the server's host-local tz — not
|
||||||
from ~150 MB fetched to a few hundred bytes; active albums now fetch
|
at 09:00 in the timezone the admin configured under Settings — because
|
||||||
O(changes) instead of O(library). Tested against a ~200k-asset library.
|
`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
|
## Bug Fixes
|
||||||
`notification_tracker_state` — applied automatically by the startup
|
|
||||||
migration, no manual step required.
|
|
||||||
|
|
||||||
## Performance
|
- **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))
|
||||||
- **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))
|
|
||||||
|
|
||||||
## Features
|
## 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>
|
<details>
|
||||||
<summary>All Commits</summary>
|
<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)*
|
- [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)*
|
||||||
- [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)*
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "notify-bridge-frontend",
|
"name": "notify-bridge-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.0",
|
"version": "0.3.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
"collectionRenamed": "collection renamed",
|
"collectionRenamed": "collection renamed",
|
||||||
"collectionDeleted": "collection deleted",
|
"collectionDeleted": "collection deleted",
|
||||||
"sharingChanged": "sharing changed",
|
"sharingChanged": "sharing changed",
|
||||||
|
"scheduledMessage": "scheduled message",
|
||||||
"actionSuccess": "action run",
|
"actionSuccess": "action run",
|
||||||
"actionPartial": "action partial",
|
"actionPartial": "action partial",
|
||||||
"actionFailed": "action failed",
|
"actionFailed": "action failed",
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
"collectionRenamed": "альбом переименован",
|
"collectionRenamed": "альбом переименован",
|
||||||
"collectionDeleted": "альбом удалён",
|
"collectionDeleted": "альбом удалён",
|
||||||
"sharingChanged": "изменение доступа",
|
"sharingChanged": "изменение доступа",
|
||||||
|
"scheduledMessage": "запланированное сообщение",
|
||||||
"actionSuccess": "действие выполнено",
|
"actionSuccess": "действие выполнено",
|
||||||
"actionPartial": "действие частично",
|
"actionPartial": "действие частично",
|
||||||
"actionFailed": "действие провалено",
|
"actionFailed": "действие провалено",
|
||||||
|
|||||||
@@ -223,6 +223,7 @@
|
|||||||
collection_renamed: 'dashboard.collectionRenamed',
|
collection_renamed: 'dashboard.collectionRenamed',
|
||||||
collection_deleted: 'dashboard.collectionDeleted',
|
collection_deleted: 'dashboard.collectionDeleted',
|
||||||
sharing_changed: 'dashboard.sharingChanged',
|
sharing_changed: 'dashboard.sharingChanged',
|
||||||
|
scheduled_message: 'dashboard.scheduledMessage',
|
||||||
action_success: 'dashboard.actionSuccess',
|
action_success: 'dashboard.actionSuccess',
|
||||||
action_partial: 'dashboard.actionPartial',
|
action_partial: 'dashboard.actionPartial',
|
||||||
action_failed: 'dashboard.actionFailed',
|
action_failed: 'dashboard.actionFailed',
|
||||||
@@ -231,11 +232,13 @@
|
|||||||
const eventIcons: Record<string, string> = {
|
const eventIcons: Record<string, string> = {
|
||||||
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
|
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
|
||||||
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
|
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
|
||||||
|
scheduled_message: 'mdiCalendarClock',
|
||||||
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
|
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
|
||||||
};
|
};
|
||||||
const eventColors: Record<string, string> = {
|
const eventColors: Record<string, string> = {
|
||||||
assets_added: '#059669', assets_removed: '#ef4444',
|
assets_added: '#059669', assets_removed: '#ef4444',
|
||||||
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
|
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
|
||||||
|
scheduled_message: '#8b5cf6',
|
||||||
action_success: '#0d9488', action_partial: '#f59e0b', action_failed: '#dc2626',
|
action_success: '#0d9488', action_partial: '#f59e0b', action_failed: '#dc2626',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-core"
|
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"
|
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -177,7 +177,9 @@ class ImmichActionExecutor(ActionExecutor):
|
|||||||
needs_thumbnail = album_id in album_created_now
|
needs_thumbnail = album_id in album_created_now
|
||||||
|
|
||||||
if album_id and album_id != "__dry_run_new__":
|
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 album is None and create_if_missing and create_album_name:
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
created = await self._client.create_album(create_album_name)
|
created = await self._client.create_album(create_album_name)
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -18,6 +21,51 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
MAX_SEARCH_QUERY_LEN = 256
|
MAX_SEARCH_QUERY_LEN = 256
|
||||||
MAX_SEARCH_PERSON_IDS = 50
|
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,
|
# User-facing error bodies — Immich responses may leak internal paths,
|
||||||
# hostnames, or headers injected by intermediary proxies. These helpers keep
|
# hostnames, or headers injected by intermediary proxies. These helpers keep
|
||||||
# only a short, scrubbed summary; full bodies are logged server-side only.
|
# only a short, scrubbed summary; full bodies are logged server-side only.
|
||||||
@@ -184,22 +232,30 @@ class ImmichClient:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def get_shared_links(self, album_id: str) -> list[SharedLinkInfo]:
|
async def get_shared_links(self, album_id: str) -> list[SharedLinkInfo]:
|
||||||
links: list[SharedLinkInfo] = []
|
bucketed = await self._get_shared_links_bucketed()
|
||||||
try:
|
return list(bucketed.get(album_id, []))
|
||||||
async with self._session.get(
|
|
||||||
f"{self._url}/api/shared-links",
|
async def _get_shared_links_bucketed(self) -> dict[str, list[SharedLinkInfo]]:
|
||||||
headers=self._headers,
|
"""Return ``{album_id: [SharedLinkInfo, ...]}`` for the server, hitting
|
||||||
) as response:
|
the module-level TTL cache first. Underlying Immich endpoint has no
|
||||||
if response.status == 200:
|
per-album filter, so one server-wide fetch serves every caller until
|
||||||
data = await response.json()
|
the TTL elapses.
|
||||||
for link in data:
|
"""
|
||||||
album = link.get("album")
|
digest = _server_digest(self._url, self._api_key)
|
||||||
key = link.get("key")
|
now = time.monotonic()
|
||||||
if album and key and album.get("id") == album_id:
|
entry = _shared_links_cache.get(digest)
|
||||||
links.append(SharedLinkInfo.from_api_response(link))
|
if entry is not None and (now - entry[0]) < _SHARED_LINKS_CACHE_TTL_SECONDS:
|
||||||
except aiohttp.ClientError as err:
|
return entry[1]
|
||||||
_LOGGER.warning("Failed to fetch shared links: %s", err)
|
|
||||||
return links
|
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]]:
|
async def get_all_shared_links_by_album(self) -> dict[str, list[SharedLinkInfo]]:
|
||||||
"""Fetch every shared link on the server, bucketed by album id.
|
"""Fetch every shared link on the server, bucketed by album id.
|
||||||
@@ -247,7 +303,29 @@ class ImmichClient:
|
|||||||
self,
|
self,
|
||||||
album_id: str,
|
album_id: str,
|
||||||
users_cache: dict[str, str] | None = None,
|
users_cache: dict[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
use_cache: bool = True,
|
||||||
) -> ImmichAlbumData | None:
|
) -> 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:
|
try:
|
||||||
async with self._session.get(
|
async with self._session.get(
|
||||||
f"{self._url}/api/albums/{album_id}",
|
f"{self._url}/api/albums/{album_id}",
|
||||||
@@ -260,10 +338,18 @@ class ImmichClient:
|
|||||||
f"Error fetching album {album_id}: HTTP {response.status}"
|
f"Error fetching album {album_id}: HTTP {response.status}"
|
||||||
)
|
)
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
return ImmichAlbumData.from_api_response(data, users_cache)
|
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
raise ImmichApiError(f"Error communicating with Immich: {err}") from 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:
|
async def get_album_meta(self, album_id: str) -> ImmichAlbumMeta | None:
|
||||||
"""Fetch album metadata without the assets array.
|
"""Fetch album metadata without the assets array.
|
||||||
|
|
||||||
|
|||||||
@@ -292,7 +292,13 @@ class ImmichServiceProvider(ServiceProvider):
|
|||||||
# the full-fetch path so removals get detected.
|
# the full-fetch path so removals get detected.
|
||||||
|
|
||||||
# Full fetch: first tick, or count-decreased, or delta-unsafe.
|
# 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:
|
if album is None:
|
||||||
# Album was deleted between meta probe and full fetch — handle
|
# Album was deleted between meta probe and full fetch — handle
|
||||||
# the deletion the same way as above.
|
# the deletion the same way as above.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from notify_bridge_core.models.events import EventType, ServiceEvent
|
from notify_bridge_core.models.events import EventType, ServiceEvent
|
||||||
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
|
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
|
||||||
@@ -57,6 +58,13 @@ SCHEDULER_VARIABLES: list[TemplateVariableDefinition] = [
|
|||||||
example="Monday",
|
example="Monday",
|
||||||
provider_type=ServiceProviderType.SCHEDULER,
|
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(
|
TemplateVariableDefinition(
|
||||||
name="custom_vars",
|
name="custom_vars",
|
||||||
type="dict",
|
type="dict",
|
||||||
@@ -83,7 +91,8 @@ class SchedulerServiceProvider(ServiceProvider):
|
|||||||
custom_variables: dict[str, str] | None = None,
|
custom_variables: dict[str, str] | None = None,
|
||||||
date_format: str = "%d.%m.%Y",
|
date_format: str = "%d.%m.%Y",
|
||||||
time_format: str = "%H:%M",
|
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:
|
) -> None:
|
||||||
self._name = name
|
self._name = name
|
||||||
self._tracker_name = tracker_name
|
self._tracker_name = tracker_name
|
||||||
@@ -91,6 +100,18 @@ class SchedulerServiceProvider(ServiceProvider):
|
|||||||
self._date_format = date_format
|
self._date_format = date_format
|
||||||
self._time_format = time_format
|
self._time_format = time_format
|
||||||
self._datetime_format = datetime_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:
|
async def connect(self) -> bool:
|
||||||
return True # virtual provider — always connected
|
return True # virtual provider — always connected
|
||||||
@@ -103,7 +124,8 @@ class SchedulerServiceProvider(ServiceProvider):
|
|||||||
collection_ids: list[str],
|
collection_ids: list[str],
|
||||||
tracker_state: dict[str, Any],
|
tracker_state: dict[str, Any],
|
||||||
) -> tuple[list[ServiceEvent], 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
|
# State uses {collection_id: {dict}} convention like other providers
|
||||||
sched_state = tracker_state.get("scheduler", {})
|
sched_state = tracker_state.get("scheduler", {})
|
||||||
fire_count = sched_state.get("fire_count", 0) + 1
|
fire_count = sched_state.get("fire_count", 0) + 1
|
||||||
@@ -115,6 +137,7 @@ class SchedulerServiceProvider(ServiceProvider):
|
|||||||
"current_time": now.strftime(self._time_format),
|
"current_time": now.strftime(self._time_format),
|
||||||
"current_datetime": now.strftime(self._datetime_format),
|
"current_datetime": now.strftime(self._datetime_format),
|
||||||
"weekday": _WEEKDAYS[now.weekday()],
|
"weekday": _WEEKDAYS[now.weekday()],
|
||||||
|
"timezone": self._tz.key,
|
||||||
"custom_vars": dict(self._custom_variables),
|
"custom_vars": dict(self._custom_variables),
|
||||||
}
|
}
|
||||||
# Flatten custom variables at top level for easy template access
|
# 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_time", event.extra.get("current_time", ""))
|
||||||
ctx.setdefault("current_datetime", event.extra.get("current_datetime", ""))
|
ctx.setdefault("current_datetime", event.extra.get("current_datetime", ""))
|
||||||
ctx.setdefault("weekday", event.extra.get("weekday", ""))
|
ctx.setdefault("weekday", event.extra.get("weekday", ""))
|
||||||
|
ctx.setdefault("timezone", event.extra.get("timezone", "UTC"))
|
||||||
ctx.setdefault("custom_vars", event.extra.get("custom_vars", {}))
|
ctx.setdefault("custom_vars", event.extra.get("custom_vars", {}))
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-server"
|
name = "notify-bridge-server"
|
||||||
version = "0.3.0"
|
version = "0.3.2"
|
||||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ async def update_settings(
|
|||||||
old_base_url = await get_setting(session, "external_url")
|
old_base_url = await get_setting(session, "external_url")
|
||||||
old_secret = await get_setting(session, "telegram_webhook_secret")
|
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_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:
|
for key in _SETTING_KEYS:
|
||||||
value = getattr(body, key, None)
|
value = getattr(body, key, None)
|
||||||
@@ -128,6 +129,14 @@ async def update_settings(
|
|||||||
|
|
||||||
new_base_url = await get_setting(session, "external_url")
|
new_base_url = await get_setting(session, "external_url")
|
||||||
new_secret = await get_setting(session, "telegram_webhook_secret")
|
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
|
# Update webhook secret in the webhook handler module
|
||||||
if new_secret != old_secret:
|
if new_secret != old_secret:
|
||||||
|
|||||||
@@ -242,6 +242,8 @@ async def get_template_variables(
|
|||||||
"current_date": "Current date (formatted)",
|
"current_date": "Current date (formatted)",
|
||||||
"current_time": "Current time (formatted)",
|
"current_time": "Current time (formatted)",
|
||||||
"current_datetime": "Current date and 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_time": "09:00",
|
||||||
"current_datetime": "22.03.2026, 09:00 UTC",
|
"current_datetime": "22.03.2026, 09:00 UTC",
|
||||||
"weekday": "Monday",
|
"weekday": "Monday",
|
||||||
|
"timezone": "UTC",
|
||||||
"custom_vars": {"team": "Engineering", "message": "Time for standup!"},
|
"custom_vars": {"team": "Engineering", "message": "Time for standup!"},
|
||||||
"team": "Engineering",
|
"team": "Engineering",
|
||||||
"message": "Time for standup!",
|
"message": "Time for standup!",
|
||||||
|
|||||||
@@ -3,11 +3,39 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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
|
_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()}
|
provider_types = {p.id: p.type for p in provider_result.all()}
|
||||||
|
|
||||||
|
tz = await _load_app_timezone()
|
||||||
|
|
||||||
for tracker in trackers:
|
for tracker in trackers:
|
||||||
job_id = f"tracker_{tracker.id}"
|
job_id = f"tracker_{tracker.id}"
|
||||||
if scheduler.get_job(job_id):
|
if scheduler.get_job(job_id):
|
||||||
@@ -306,7 +336,7 @@ async def _load_tracker_jobs() -> None:
|
|||||||
cron_expr = filters.get("cron_expression", "")
|
cron_expr = filters.get("cron_expression", "")
|
||||||
if cron_expr:
|
if cron_expr:
|
||||||
try:
|
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
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
@@ -337,10 +367,18 @@ def _add_cron_job(
|
|||||||
tracker_id: int,
|
tracker_id: int,
|
||||||
cron_expression: str,
|
cron_expression: str,
|
||||||
tracker_name: str,
|
tracker_name: str,
|
||||||
|
tz: ZoneInfo,
|
||||||
) -> None:
|
) -> 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
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
trigger = CronTrigger.from_crontab(cron_expression)
|
trigger = CronTrigger.from_crontab(cron_expression, timezone=tz)
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
_poll_tracker,
|
_poll_tracker,
|
||||||
trigger,
|
trigger,
|
||||||
@@ -349,7 +387,10 @@ def _add_cron_job(
|
|||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
max_instances=1,
|
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(
|
async def schedule_tracker(
|
||||||
@@ -371,7 +412,8 @@ async def schedule_tracker(
|
|||||||
|
|
||||||
if cron_expression:
|
if cron_expression:
|
||||||
try:
|
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
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.error("Invalid cron for tracker %d: %s — using interval", tracker_id, 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()
|
actions = result.all()
|
||||||
|
|
||||||
|
tz = await _load_app_timezone()
|
||||||
|
|
||||||
for action in actions:
|
for action in actions:
|
||||||
job_id = f"action_{action.id}"
|
job_id = f"action_{action.id}"
|
||||||
if scheduler.get_job(job_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:
|
if action.schedule_type == "cron" and action.schedule_cron:
|
||||||
try:
|
try:
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
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(
|
scheduler.add_job(
|
||||||
_run_action,
|
_run_action,
|
||||||
trigger,
|
trigger,
|
||||||
@@ -523,8 +567,8 @@ async def _load_action_jobs() -> None:
|
|||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Scheduled action %d (%s) with cron: %s",
|
"Scheduled action %d (%s) with cron: %s [tz=%s]",
|
||||||
action.id, action.name, action.schedule_cron,
|
action.id, action.name, action.schedule_cron, tz.key,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -563,7 +607,8 @@ async def schedule_action(
|
|||||||
if schedule_type == "cron" and cron_expression:
|
if schedule_type == "cron" and cron_expression:
|
||||||
try:
|
try:
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
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(
|
scheduler.add_job(
|
||||||
_run_action,
|
_run_action,
|
||||||
trigger,
|
trigger,
|
||||||
@@ -571,7 +616,10 @@ async def schedule_action(
|
|||||||
args=[action_id],
|
args=[action_id],
|
||||||
replace_existing=True,
|
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
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.error("Invalid cron for action %d: %s — using interval", action_id, 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)
|
_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:
|
async def _run_action(action_id: int) -> None:
|
||||||
"""Run an action (called by APScheduler)."""
|
"""Run an action (called by APScheduler)."""
|
||||||
from .action_runner import run_action
|
from .action_runner import run_action
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
|||||||
name=provider_name,
|
name=provider_name,
|
||||||
tracker_name=tracker_name,
|
tracker_name=tracker_name,
|
||||||
custom_variables=custom_vars,
|
custom_variables=custom_vars,
|
||||||
|
timezone_name=app_tz,
|
||||||
)
|
)
|
||||||
events, new_state = await sched.poll(collection_ids, state_dict)
|
events, new_state = await sched.poll(collection_ids, state_dict)
|
||||||
elif provider_type == "nut":
|
elif provider_type == "nut":
|
||||||
@@ -317,6 +318,26 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
|||||||
|
|
||||||
for event in events:
|
for event in events:
|
||||||
assets_count = event.added_count or event.removed_count or 0
|
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(
|
log = EventLog(
|
||||||
user_id=tracker.user_id,
|
user_id=tracker.user_id,
|
||||||
tracker_id=tracker_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_id=event.collection_id,
|
||||||
collection_name=event.collection_name,
|
collection_name=event.collection_name,
|
||||||
assets_count=assets_count,
|
assets_count=assets_count,
|
||||||
details={
|
details=details,
|
||||||
"added_count": event.added_count,
|
|
||||||
"removed_count": event.removed_count,
|
|
||||||
"provider_type": event.provider_type.value,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
session.add(log)
|
session.add(log)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user