Compare commits

...

5 Commits

Author SHA1 Message Date
alexei.dolgolyov 187b889c45 chore: release v0.5.1
Release / release (push) Successful in 1m49s
2026-04-24 19:21:39 +03:00
alexei.dolgolyov b61394f057 feat(immich): per-album scheduled/memory dispatch + template tooling
Dispatch: honor {kind}_collection_mode on TrackingConfig — "per_collection"
fans out one event per album; "combined" pools assets as before. Extract
build_immich_dispatch_events shared by cron and test paths.

Assets: collect_scheduled_assets attaches album_name/album_url/album_public_url
to each asset so combined-mode templates can attribute rows to their source
album. Default scheduled_assets templates render a multi-album header with
inline album list and per-row album link; memory_mode follows the same pattern.

UI: "Reset to default" buttons on notification and command template slots
(per-slot and whole-template), backed by new GET /*-template-configs/defaults
endpoints. tracking-configs "Preview template" now opens an inline preview
modal with locale tabs instead of navigating away; Edit button deep-links
with ?edit_slot=<name> so the destination auto-opens the config and scrolls
to the slot. Reset confirmations use ConfirmModal instead of window.confirm.

Fixes:
* NotificationDispatcher._session_ctx infinite recursion when no shared
  aiohttp.ClientSession was passed — broke test dispatch for periodic/
  scheduled/memory (cron path was unaffected).
* telegram-bots /chats/{id}/test now resolves chat.language_override /
  language_code instead of using the raw ?locale query param, matching
  the resolution the tracker-target test endpoint already used.
* scheduled_assets default template no longer emits a blank line between
  header and the first asset when the multi-album branch is taken.
2026-04-24 19:15:54 +03:00
alexei.dolgolyov be15463fd2 feat(telegram): add 'none' listener mode for bots
Introduce a third update_mode option alongside polling/webhook. 'none'
disables both polling and webhook delivery — useful when another instance
owns the listener or when the bot is send-only. Switching into 'none' now
unschedules polling and unregisters any active webhook so Telegram stops
delivering updates.

New bots default to 'none' (safer when multiple bridges share a token).
Existing bots upgraded from a pre-update_mode schema keep 'polling' so
their behavior is unchanged.
2026-04-24 15:15:25 +03:00
alexei.dolgolyov 461fb495d7 chore: release v0.5.0
Release / release (push) Successful in 1m10s
2026-04-24 14:16:34 +03:00
alexei.dolgolyov 309dec2b44 feat(immich): wire cron-fired scheduled/periodic/memory dispatch
The scheduled_enabled / scheduled_times (and the periodic / memory
counterparts) on TrackingConfig had been wired into the model, the
API, and the test-dispatch path — but no production scheduler ever
read them, so users saw the slot in the UI and only ever got fires
through "Test". This adds the missing cron jobs and the dispatch
fan-out, both keyed off the app-level IANA timezone.

* services/scheduled_dispatch.py — production fan-out reusing the
  test-path event builders, picking the slot template per kind, and
  writing an EventLog row per fire so the dashboard reflects it.
* services/scheduler.py — _load_immich_dispatch_jobs builds one
  CronTrigger per (tracker, kind, HH:MM) from the tracker's default
  TrackingConfig; reschedule_immich_dispatch_jobs rebuilds them all
  on any relevant CRUD or timezone change.
* tracker / link / tracking-config CRUD endpoints now invalidate.

Also: skip dispatch when scheduled/memory yield zero matching assets
(prevents header-only "On this day:" spam), and update the EN/RU
default scheduled_assets templates to surface that the delivery is
a scheduled random selection.
2026-04-24 12:49:47 +03:00
52 changed files with 1613 additions and 188 deletions
+15 -51
View File
@@ -1,69 +1,33 @@
# v0.4.0 (2026-04-23)
# v0.5.1 (2026-04-24)
A production-readiness release focused on hardening the service for real-world deployment: end-to-end structured logging with runtime controls, a broad security and runtime review across the HTTP, auth, DB, and scheduler layers, and a new pre-migration database snapshot that makes upgrades recoverable with a single file restore. Release CI and the Docker image build were also reworked for speed and reliability.
Extends the Immich scheduled/memory dispatch shipped in v0.5.0 with a per-album fan-out mode and rich multi-album templates, adds "Reset to default" tooling and an inline preview modal for notification / command templates, and introduces a `none` listener mode for Telegram bots (safer default for shared-token deployments). Also fixes an infinite-recursion bug in the notification dispatcher that was breaking test dispatch for periodic / scheduled / memory slots.
## Features
- **Production-grade logging** with per-request correlation (`request_id` / `command` / `chat_id` / `bot_id` / `dispatch_id`), secret masking in both messages and tracebacks, JSON or text format, runtime log level + per-module overrides editable from the settings UI, and env-var boot overrides (`NOTIFY_BRIDGE_LOG_LEVEL` / `_FORMAT` / `_LEVELS`). Closes every silent drop in the Telegram send path — `/random` and media-group failures now log `WARN` / `ERROR` with full context instead of disappearing ([f50d465](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f50d465))
- **Production-readiness hardening across security, async, DB, and ops** ([920920b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/920920b)):
- *Security:* async SSRF-safe DNS resolver; `allow_redirects=False` on all outbound clients; Matrix `homeserver_url` validation; rejection of `***`-masked secrets on provider / email-bot updates; bcrypt moved off the event loop; JWT `iss` / `aud` + leeway with strict claim rejection; setup TOCTOU closed inside a transaction; expanded rate limits; constant-time login; config rejects known dev secret keys and validates CORS / ports / token lifetimes; webhook bodies capped at 1 MiB; Discord 429 retries bounded; CSP + HSTS headers added.
- *Async / runtime:* SQLite engine tuned (WAL, `synchronous=NORMAL`, `foreign_keys=ON`, busy timeout, pool pre-ping); ordered lifespan shutdown; shared `aiohttp` session race-free; blocking storage / backup writes offloaded to threads; NUT client timeouts; Telegram poller switched from 3 s short-poll to 30 s + 25 s long-poll (~10x fewer API calls).
- *Database:* new performance-index migration covering every FK and hot-path composite; new `schema_version` table; `__system__` placeholder user (`id=0`) seeded to satisfy FKs; `list_notification_trackers` rewritten from `1+N+N*M` to batched loads; retention job extended to event / webhook / action-execution logs.
- *Scheduler:* `AsyncIOScheduler` job defaults set (`coalesce`, `misfire_grace_time=300`, `max_instances=1`).
- *Ops:* uvicorn runs with `proxy_headers` / `forwarded_allow_ips` / graceful shutdown timeout; access log suppressed outside debug; FastAPI version read from `importlib.metadata`; new `/api/ready` endpoint; docker-compose adds resource / PID limits, `read_only` + tmpfs, `cap_drop: ALL`, `no-new-privileges`, drops the `ALLOW_PRIVATE_URLS=1` default, and points healthcheck at `/api/ready`.
- *Frontend:* `/login` redirects already-authenticated users to `/` and shows a distinct "backend unreachable" banner (en / ru) when `/auth/needs-setup` fails.
- **Pre-migration SQLite snapshots** via `VACUUM INTO` at lifespan startup — takes a consistent, atomic copy of the DB before migrations run, so a botched upgrade is recoverable by restoring a single file. Safe under WAL; best-effort (failures log but never raise); configurable via `NOTIFY_BRIDGE_PRE_MIGRATE_SNAPSHOT_KEEP` (default 5; 0 disables). Snapshots land in `data_dir/backups/pre-migrate-<ts>.db` and the N oldest are pruned each boot ([7cbb02b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7cbb02b))
- **Per-album Immich dispatch for scheduled / memory slots** — honors the new `{kind}_collection_mode` on `TrackingConfig`: `per_collection` fans out one event per album, `combined` pools assets as before. Combined mode now attaches `album_name` / `album_url` / `album_public_url` to each asset so templates can attribute rows to their source album. Default `scheduled_assets` and `memory_mode` templates render a multi-album header with an inline album list and per-row album link. The cron and test-dispatch paths now share a single `build_immich_dispatch_events` helper ([b61394f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b61394f)).
- **"Reset to default" for template slots** — new per-slot and whole-template reset buttons on notification and command template configs, backed by `GET /*-template-configs/defaults` endpoints. Confirmations use the app's `ConfirmModal` instead of `window.confirm` ([b61394f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b61394f)).
- **Inline template preview + deep-link edit** — tracking-configs "Preview template" now opens an inline preview modal with locale tabs instead of navigating away. The Edit button deep-links with `?edit_slot=<name>` so the destination auto-opens the config and scrolls to the requested slot ([b61394f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b61394f)).
- **Telegram bot `none` listener mode** — third option alongside polling and webhook. Disables both long-polling and webhook delivery; useful when another instance owns the listener or the bot is send-only. Switching into `none` unschedules polling and unregisters the active webhook so Telegram stops delivering updates ([be15463](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/be15463)).
## Bug Fixes
- Allow `unsafe-inline` scripts in CSP so SvelteKit's hydration bootstrap inline `<script>` runs in production — without it the frontend failed to hydrate under the hardened CSP introduced in this release ([8f0346e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8f0346e))
- Fix `NotificationDispatcher._session_ctx` infinite recursion when no shared `aiohttp.ClientSession` was passed — broke test dispatch for periodic / scheduled / memory slots (cron path was unaffected) ([b61394f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b61394f)).
- `telegram-bots /chats/{id}/test` now resolves `chat.language_override` / `language_code` instead of using the raw `?locale` query param, matching the resolution the tracker-target test endpoint already used ([b61394f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b61394f)).
- Default `scheduled_assets` template no longer emits a blank line between the header and the first asset when the multi-album branch is taken ([b61394f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b61394f)).
## Upgrade Notes
- `ALLOW_PRIVATE_URLS=1` is no longer set by default in `docker-compose.yml`. If your deployment targets private network URLs, set it explicitly.
- Docker healthchecks now probe `/api/ready` (separate from `/api/health`); update any external monitors accordingly.
- Config startup now rejects known dev secret keys — set real values (e.g. `JWT_SECRET`) before upgrading.
- Log format and level can now be changed at runtime from the settings UI; the `log_format` field still requires a restart to apply (a `WARN` is logged noting this).
---
## Development / Internal
### Tests
- New `packages/server/tests/` suite with 29 passing tests: config validation; JWT round-trip and `aud` / `alg=none` rejection; SSRF scheme and private-range enforcement (sync + async); Discord bounded retry; a lifespan-level `/api/health` + `/api/ready` smoke check. `services/test_dispatch.py` renamed to `manual_dispatch.py` so pytest no longer auto-collects production code ([920920b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/920920b))
### CI / Build
- CI now runs on push / PR with frontend `svelte-check` + build, and a non-push image build. Release workflow is gated on tests and publishes an immutable `sha-<commit>` image tag ([920920b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/920920b))
- Install editable packages inside a venv ([2bec253](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2bec253))
- Cache pip downloads and collapse install into a single pip call ([3b683ce](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b683ce))
- Drop backend pytest from Gitea CI — editable install is too slow on the hosted runner ([f904037](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f904037))
- Skip `build.yml` on release commits to avoid redundant runs ([bbcdf1c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bbcdf1c))
- Drop Trivy scan from release (output was discarded and it never failed) ([19036a9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/19036a9))
### Performance
- Split external Docker deps into a cacheable layer and swap pip for uv for faster image builds ([592e1b6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/592e1b6))
- Install uv from PyPI instead of the ghcr.io distroless image to avoid slow GHCR pulls on CI ([a6a854a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a6a854a))
- **New Telegram bots default to `none`** (safer when multiple bridges share a token). Existing bots upgraded from a pre-`update_mode` schema keep `polling`, so their behavior is unchanged. When creating a new bot, explicitly switch to `polling` or `webhook` if you want it to receive updates.
- A new `{kind}_collection_mode` field was added to `TrackingConfig` for Immich scheduled/memory slots. Existing trackers keep the previous `combined` behavior by default; switch to `per_collection` per-tracker to opt in to one-event-per-album fan-out.
---
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
| ---- | ------- | ------ |
| [8f0346e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8f0346e) | fix(csp): allow unsafe-inline scripts for SvelteKit hydration bootstrap | alexei.dolgolyov |
| [a6a854a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a6a854a) | perf(docker): install uv from PyPI instead of ghcr.io (avoid slow GHCR pulls) | alexei.dolgolyov |
| [19036a9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/19036a9) | ci: drop trivy scan from release (never failed, output discarded) | alexei.dolgolyov |
| [592e1b6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/592e1b6) | perf(docker): split external deps into a cacheable layer, swap pip for uv | alexei.dolgolyov |
| [bbcdf1c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bbcdf1c) | ci: skip build.yml on release commits | alexei.dolgolyov |
| [f904037](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f904037) | ci: drop backend pytest stage (too slow on hosted runner) | alexei.dolgolyov |
| [3b683ce](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b683ce) | ci: cache pip downloads and collapse install into one pip call | alexei.dolgolyov |
| [2bec253](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2bec253) | ci: install editable packages inside a venv | alexei.dolgolyov |
| [7cbb02b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7cbb02b) | feat(db): pre-migration SQLite snapshots via VACUUM INTO | alexei.dolgolyov |
| [920920b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/920920b) | feat: production-readiness hardening across security, async, DB, ops | alexei.dolgolyov |
| [f50d465](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f50d465) | feat(logging): production-grade logging with context vars, secret masking, and runtime level control | alexei.dolgolyov |
| Hash | Message | Author |
|------------------------------------------------------------------------------------------|----------------------------------------------------------------------|------------------|
| [b61394f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b61394f) | feat(immich): per-album scheduled/memory dispatch + template tooling | alexei.dolgolyov |
| [be15463](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/be15463) | feat(telegram): add 'none' listener mode for bots | alexei.dolgolyov |
</details>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.4.0",
"version": "0.5.1",
"type": "module",
"scripts": {
"dev": "vite dev",
+29 -3
View File
@@ -262,7 +262,14 @@
"testPeriodic": "Test periodic summary",
"testScheduled": "Test scheduled assets",
"testMemory": "Test memory / On This Day",
"testDisabledHint": "Enable this feature in the tracker's default Tracking Config first.",
"checkingLinks": "Checking links...",
"featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.",
"openTrackingConfig": "Open Tracking Config",
"linkReplace": "Replace",
"linkReplacing": "Replacing...",
"linkReplaceFailed": "Failed to replace link for \"{name}\"",
"linkPasswordProtectedNote": "Telegram users can't open password-protected links without the password. Remove the password in Immich or replace the link.",
"missingLinksTitle": "Albums Missing Public Links",
"missingLinksDesc": "The following albums don't have public shared links. Without links, notification recipients won't be able to view photos.",
"expired": "Expired",
@@ -433,6 +440,8 @@
"webhookRegistered": "Webhook registered",
"webhookUnregistered": "Webhook unregistered",
"updateMode": "Update mode",
"none": "None",
"noneActive": "Listener disabled",
"polling": "Polling",
"webhook": "Webhook",
"webhookStatus": "Webhook status",
@@ -550,7 +559,14 @@
"renamed": "renamed",
"deleted": "deleted",
"providerType": "Provider Type",
"sortRandom": "Random"
"sortRandom": "Random",
"timesInlineHelp": "HH:MM, comma-separated",
"invalidTimeList": "Use HH:MM format, e.g. 09:00 or 09:00, 18:30",
"previewTemplate": "Preview template",
"previewSampleNote": "Rendered with sample data — not your real assets. Shows the shipped default template.",
"editTemplate": "Edit template",
"quietHoursZero": "Quiet period is 0 minutes — adjust times",
"nextDay": "next day"
},
"templateConfig": {
"title": "Template Configs",
@@ -596,7 +612,14 @@
"confirmDelete": "Delete this template config?",
"invalidFormat": "Invalid format string",
"filterSlots": "Filter slots...",
"slots": "slots"
"slots": "slots",
"resetToDefault": "Reset to default",
"resetAllToDefaults": "Reset all to defaults",
"resetSlotConfirm": "Replace this slot's {locale} template with the shipped default? Your current edits will be lost.",
"resetAllConfirm": "Replace every slot's {locale} template with the shipped defaults? All your {locale} edits will be lost.",
"resetNoDefault": "No shipped default for this slot.",
"resetApplied": "Reset to default (not saved yet — click Save to persist)",
"deepLinkNoConfig": "No template config found for this provider. Create one first."
},
"templateVars": {
"message_assets_added": {
@@ -713,9 +736,12 @@
"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.",
"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": "Reference date in the app timezone. The first summary fires at the next configured HH:MM on/after this date, then every N days.",
"intervalDays": "Days between successive summaries. 1 = daily, 7 = weekly.",
"times": "Time(s) of day to send notifications, in HH:MM format. Use commas for multiple times: 09:00,18:00",
"albumMode": "Per album: separate notification per album. Combined: one notification with all albums. Random: pick one album randomly.",
"scheduledAlbumMode": "How albums are grouped in scheduled deliveries. Default: Per album (one notification per tracked album).",
"memoryAlbumMode": "How albums are grouped in memory deliveries. Default: Combined (a single notification aggregating matches from all tracked albums).",
"minRating": "Only include assets with at least this star rating (0 = no filter).",
"eventMessages": "Templates for real-time event notifications. Use {variables} for dynamic content.",
"assetFormatting": "How individual assets are formatted within notification messages.",
+29 -3
View File
@@ -262,7 +262,14 @@
"testPeriodic": "Тест периодической сводки",
"testScheduled": "Тест запланированных фото",
"testMemory": "Тест воспоминаний",
"testDisabledHint": "Сначала включите эту функцию в привязанной конфигурации отслеживания.",
"checkingLinks": "Проверка ссылок...",
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
"openTrackingConfig": "Открыть конфигурацию отслеживания",
"linkReplace": "Пересоздать",
"linkReplacing": "Пересоздание...",
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
"linkPasswordProtectedNote": "Получатели в Telegram не смогут открыть защищённую паролем ссылку без пароля. Снимите пароль в Immich или пересоздайте ссылку.",
"missingLinksTitle": "Альбомы без публичных ссылок",
"missingLinksDesc": "У следующих альбомов нет публичных ссылок. Без ссылок получатели уведомлений не смогут просматривать фото.",
"expired": "Истёк",
@@ -433,6 +440,8 @@
"webhookRegistered": "Вебхук зарегистрирован",
"webhookUnregistered": "Вебхук удалён",
"updateMode": "Режим обновлений",
"none": "Откл.",
"noneActive": "Приём обновлений отключён",
"polling": "Опрос",
"webhook": "Вебхук",
"webhookStatus": "Статус вебхука",
@@ -550,7 +559,14 @@
"renamed": "переименование",
"deleted": "удалён",
"providerType": "Тип провайдера",
"sortRandom": "Случайный"
"sortRandom": "Случайный",
"timesInlineHelp": "ЧЧ:ММ, через запятую",
"invalidTimeList": "Используйте формат ЧЧ:ММ, например 09:00 или 09:00, 18:30",
"previewTemplate": "Предпросмотр шаблона",
"previewSampleNote": "Отрисовано на демо-данных, не на ваших реальных фото. Показан шаблон по умолчанию.",
"editTemplate": "Редактировать шаблон",
"quietHoursZero": "Тихий период 0 минут — скорректируйте время",
"nextDay": "след. день"
},
"templateConfig": {
"title": "Конфигурации шаблонов",
@@ -596,7 +612,14 @@
"confirmDelete": "Удалить эту конфигурацию шаблона?",
"invalidFormat": "Некорректная строка формата",
"filterSlots": "Фильтр слотов...",
"slots": "слотов"
"slots": "слотов",
"resetToDefault": "Сбросить к умолчанию",
"resetAllToDefaults": "Сбросить все к умолчаниям",
"resetSlotConfirm": "Заменить шаблон этого слота ({locale}) на исходный по умолчанию? Ваши правки будут потеряны.",
"resetAllConfirm": "Заменить шаблоны всех слотов ({locale}) на исходные по умолчанию? Все ваши правки для {locale} будут потеряны.",
"resetNoDefault": "Для этого слота нет шаблона по умолчанию.",
"resetApplied": "Сброшено к умолчанию (ещё не сохранено — нажмите «Сохранить»)",
"deepLinkNoConfig": "Не найдено конфигурации шаблонов для этого провайдера. Сначала создайте её."
},
"templateVars": {
"message_assets_added": {
@@ -713,9 +736,12 @@
"quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:0007:00.",
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
"periodicStartDate": "Опорная дата в часовом поясе приложения. Первая сводка отправится в ближайшее заданное время ЧЧ:ММ, начиная с этой даты, затем каждые N дней.",
"intervalDays": "Период между сводками в днях. 1 = ежедневно, 7 = еженедельно.",
"times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00",
"albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.",
"scheduledAlbumMode": "Как альбомы группируются в запланированных отправках. По умолчанию: По альбому (одно уведомление на каждый отслеживаемый альбом).",
"memoryAlbumMode": "Как альбомы группируются в воспоминаниях. По умолчанию: Объединённый (одно уведомление со всеми совпадениями из всех альбомов).",
"minRating": "Включать только ассеты с рейтингом не ниже указанного (0 = без фильтра).",
"eventMessages": "Шаблоны уведомлений о событиях в реальном времени. Используйте {переменные} для динамического контента.",
"assetFormatting": "Форматирование отдельных ассетов в сообщениях уведомлений.",
+22 -13
View File
@@ -1,5 +1,12 @@
import type { ProviderDescriptor } from './types';
/**
* Today's date in ISO (YYYY-MM-DD) — used as the default for
* `periodic_start_date` so new configs anchor to "today" rather than a
* hardcoded date that gets further into the past on every release.
*/
const todayIso = (): string => new Date().toISOString().slice(0, 10);
export const immichDescriptor: ProviderDescriptor = {
type: 'immich',
defaultName: 'Immich',
@@ -58,17 +65,17 @@ export const immichDescriptor: ProviderDescriptor = {
key: 'periodic', legend: 'trackingConfig.periodicSummary', legendHint: 'hints.periodicSummary',
enabledField: 'periodic_enabled', enabledDefault: false,
fields: [
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1 },
{ key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'number', defaultValue: '2025-01-01' }, // rendered as date input
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'number', defaultValue: '12:00' }, // rendered as text input
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1, hint: 'hints.intervalDays' },
{ key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'date', defaultValue: todayIso, hint: 'hints.periodicStartDate' },
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '12:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
],
},
{
key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets',
enabledField: 'scheduled_enabled', enabledDefault: false,
fields: [
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' },
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection' },
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection', hint: 'hints.scheduledAlbumMode' },
{ key: 'scheduled_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
{ key: 'scheduled_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
{ key: 'scheduled_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
@@ -79,21 +86,21 @@ export const immichDescriptor: ProviderDescriptor = {
key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode',
enabledField: 'memory_enabled', enabledDefault: false,
fields: [
{ key: 'memory_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' },
{ key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined' },
{ key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10 },
{ key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
{ key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined', hint: 'hints.memoryAlbumMode' },
{ key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
{ key: 'memory_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
{ key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0 },
{ key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
{ key: 'memory_favorite_only', label: 'trackingConfig.favoritesOnly', type: 'toggle', defaultValue: false, hint: 'hints.favoritesOnly' },
{ 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', hint: 'hints.memorySource' },
],
},
{
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' },
{ key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'time', defaultValue: '22:00' },
{ key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'time', defaultValue: '07:00' },
],
},
],
@@ -114,7 +121,9 @@ export const immichDescriptor: ProviderDescriptor = {
const warnings: { id: string; name: string; issue: string }[] = [];
// Run shared-link checks in parallel with a concurrency cap so a large
// album set doesn't stall the save button for seconds.
// album set doesn't stall the save button for seconds. Cap of 6 keeps
// the save dialog responsive for users with 50+ albums while staying
// well under typical Immich per-IP rate limits.
const CONCURRENCY = 6;
async function checkOne(albumId: string): Promise<void> {
try {
+5 -2
View File
@@ -47,17 +47,20 @@ export function allProviderTypes(): string[] {
*/
export function buildTrackingFormDefaults(): Record<string, any> {
const defaults: Record<string, any> = {};
// `defaultValue` may be a function (for time-sensitive defaults like
// today's date) so the computed value is fresh each time the form resets.
const resolve = (v: unknown): unknown => (typeof v === 'function' ? (v as () => unknown)() : v);
for (const desc of REGISTRY.values()) {
for (const field of desc.eventFields) {
defaults[field.key] = field.default;
}
for (const extra of desc.extraTrackingFields ?? []) {
defaults[extra.key] = extra.defaultValue ?? '';
defaults[extra.key] = resolve(extra.defaultValue) ?? '';
}
for (const section of desc.featureSections ?? []) {
defaults[section.enabledField] = section.enabledDefault;
for (const f of section.fields) {
defaults[f.key] = f.defaultValue ?? '';
defaults[f.key] = resolve(f.defaultValue) ?? '';
}
for (const cb of section.checkboxes ?? []) {
defaults[cb.key] = cb.default;
+19 -2
View File
@@ -60,14 +60,31 @@ export interface EventTrackingField {
export interface ExtraTrackingField {
key: string;
label: string;
type: 'number' | 'grid-select' | 'toggle';
/**
* Control kind:
* - `number` — numeric spinner
* - `grid-select` — icon-grid chooser (requires `gridItems`)
* - `toggle` — on/off switch
* - `date` — HTML date picker (YYYY-MM-DD)
* - `time` — HTML time picker (HH:MM)
* - `time-list` — comma-separated HH:MM list, validated on blur
*/
type: 'number' | 'grid-select' | 'toggle' | 'date' | 'time' | 'time-list';
/** Grid-select item source function name from grid-items.ts. */
gridItems?: string;
gridColumns?: number;
hint?: string;
/** Inline helper text rendered under the input (not a tooltip). */
inlineHelp?: string;
min?: number;
max?: number;
defaultValue?: string | number | boolean;
/** For time-list: show live validation + auto-normalize on blur. */
validateFormat?: boolean;
/**
* Default value. Can be a function for dynamic values (e.g. today's date)
* evaluated each time the form is reset.
*/
defaultValue?: string | number | boolean | (() => string | number | boolean);
}
/** A feature section like periodic summary, scheduled assets, memory mode. */
+20 -3
View File
@@ -334,10 +334,12 @@
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
{/if}
<!-- Mode badge -->
<span class="text-xs px-1.5 py-0.5 rounded font-mono {bot.update_mode === 'webhook'
<span class="text-xs px-1.5 py-0.5 rounded font-mono {(bot.update_mode || 'none') === 'webhook'
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
: 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'}">
{bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')}
: (bot.update_mode || 'none') === 'polling'
? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'
: 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{(bot.update_mode || 'none') === 'webhook' ? t('telegramBot.webhook') : (bot.update_mode || 'none') === 'polling' ? t('telegramBot.polling') : t('telegramBot.none')}
</span>
</div>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
@@ -456,6 +458,14 @@
<p class="text-xs font-medium mb-2">{t('telegramBot.updateMode')}</p>
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
<button onclick={() => switchMode(bot.id, 'none')}
disabled={modeChanging[bot.id] || (bot.update_mode || 'none') === 'none'}
class="px-3 py-1 text-xs transition-colors {(bot.update_mode || 'none') === 'none'
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
: 'hover:bg-[var(--color-muted)]'} disabled:opacity-70">
<MdiIcon name="mdiBellOff" size={14} />
{t('telegramBot.none')}
</button>
<button onclick={() => switchMode(bot.id, 'polling')}
disabled={modeChanging[bot.id] || bot.update_mode === 'polling'}
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'polling'
@@ -474,6 +484,13 @@
</button>
</div>
{#if (bot.update_mode || 'none') === 'none'}
<span class="text-xs text-[var(--color-muted-foreground)] flex items-center gap-1">
<MdiIcon name="mdiBellOff" size={14} />
{t('telegramBot.noneActive')}
</span>
{/if}
{#if bot.update_mode === 'polling'}
<span class="text-xs text-[var(--color-success-fg)] flex items-center gap-1">
<MdiIcon name="mdiCheckCircle" size={14} />
@@ -54,6 +54,11 @@
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
let confirmReset = $state<{
kind: 'slot' | 'all';
slotKey?: string;
message: string;
} | null>(null);
let slotPreview = $state<Record<string, string>>({});
let slotErrors = $state<Record<string, string>>({});
let slotErrorLines = $state<Record<string, number | null>>({});
@@ -253,6 +258,58 @@
}
}
function resetSlotToDefault(slotKey: string) {
if (!form.provider_type) return;
confirmReset = {
kind: 'slot',
slotKey,
message: t('templateConfig.resetSlotConfirm').replace('{locale}', activeLocale.toUpperCase()),
};
}
function resetAllToDefaults() {
if (!form.provider_type) return;
confirmReset = {
kind: 'all',
message: t('templateConfig.resetAllConfirm').replace(/\{locale\}/g, activeLocale.toUpperCase()),
};
}
async function performReset() {
if (!confirmReset || !form.provider_type) return;
const { kind, slotKey } = confirmReset;
confirmReset = null;
try {
if (kind === 'slot' && slotKey) {
const res = await api<Record<string, Record<string, string>>>(
`/command-template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&slot_name=${encodeURIComponent(slotKey)}&locale=${encodeURIComponent(activeLocale)}`,
);
const text = res?.[slotKey]?.[activeLocale];
if (!text) {
snackError(t('templateConfig.resetNoDefault'));
return;
}
setSlotValue(slotKey, text);
validateSlot(slotKey, text, true);
} else {
const res = await api<Record<string, Record<string, string>>>(
`/command-template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&locale=${encodeURIComponent(activeLocale)}`,
);
const nextSlots = { ...form.slots };
for (const [key, localeMap] of Object.entries(res || {})) {
const text = localeMap?.[activeLocale];
if (text === undefined) continue;
nextSlots[key] = { ...(nextSlots[key] || {}), [activeLocale]: text };
}
form.slots = nextSlots;
refreshAllPreviews();
}
snackSuccess(t('templateConfig.resetApplied'));
} catch (err: any) {
snackError(err.message);
}
}
function clone(c: CmdTemplateConfig) {
const slotsCopy: Record<string, Record<string, string>> = {};
for (const [k, v] of Object.entries(c.slots)) {
@@ -343,7 +400,7 @@
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
<!-- Locale tabs -->
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]">
{#each LOCALES as loc}
<button type="button"
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
@@ -351,6 +408,14 @@
{loc.toUpperCase()}
</button>
{/each}
{#if form.provider_type}
<button type="button" onclick={resetAllToDefaults}
title={t('templateConfig.resetAllToDefaults')}
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
<MdiIcon name="mdiRefresh" size={12} />
{t('templateConfig.resetAllToDefaults')}
</button>
{/if}
</div>
<!-- Slot filter -->
@@ -381,6 +446,11 @@
<button type="button" onclick={() => showVarsFor = slot.name}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
title={t('templateConfig.resetToDefault')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{t('templateConfig.resetToDefault')}
</button>
</div>
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
@@ -472,6 +542,14 @@
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<ConfirmModal open={confirmReset !== null}
title={t('templateConfig.resetToDefault')}
message={confirmReset?.message || ''}
confirmLabel={confirmReset?.kind === 'all' ? t('templateConfig.resetAllToDefaults') : t('templateConfig.resetToDefault')}
confirmIcon="mdiRefresh"
onconfirm={performReset}
oncancel={() => confirmReset = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal -->
@@ -84,17 +84,23 @@
let testMenuStyle = $state('');
// Test types: basic is always available; periodic/scheduled/memory only for providers
// that have those notification slots in their capabilities
const allTestTypes: Record<string, { key: string; icon: string; labelKey: string; requiredSlot?: string }> = {
// that have those notification slots in their capabilities AND have the feature
// enabled on the tracker's default TrackingConfig. A disabled feature on the
// default config means cron dispatch won't fire it in production either — so
// the test button would just surface a silent skip.
const allTestTypes: Record<string, {
key: string; icon: string; labelKey: string;
requiredSlot?: string; enabledField?: string;
}> = {
basic: { key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message' },
scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message' },
memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message' },
periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message', enabledField: 'periodic_enabled' },
scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message', enabledField: 'scheduled_enabled' },
memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message', enabledField: 'memory_enabled' },
};
let testMenuTrackerId = $state<number | null>(null);
let testTypes = $derived.by(() => {
const base = [allTestTypes.basic];
const base: { key: string; icon: string; labelKey: string; disabledReason?: string }[] = [allTestTypes.basic];
if (!testMenuTrackerId) return base;
const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId);
if (!tracker) return base;
@@ -103,8 +109,18 @@
const caps = allCapabilities[provider.type];
if (!caps) return base;
const slotNames = new Set((caps.notification_slots || []).map((s: any) => s.name));
const defaultTc = trackingConfigs.find(c => c.id === tracker.default_tracking_config_id);
for (const tt of [allTestTypes.periodic, allTestTypes.scheduled, allTestTypes.memory]) {
if (tt.requiredSlot && slotNames.has(tt.requiredSlot)) base.push(tt);
if (!tt.requiredSlot || !slotNames.has(tt.requiredSlot)) continue;
const enabled = !!defaultTc && !!tt.enabledField && !!(defaultTc as any)[tt.enabledField];
base.push({
key: tt.key, icon: tt.icon, labelKey: tt.labelKey,
// When surfaced, the button still renders but is disabled and
// shows *why* — users who land here via the test menu without
// having toggled the feature on Tracking Config see a clear
// pointer to the missing setting instead of a silent failure.
disabledReason: enabled ? undefined : 'notificationTracker.testDisabledHint',
});
}
return base;
});
@@ -516,6 +532,15 @@
onclose={() => { linkWarning = null; }}
onautoCreate={autoCreateLinks}
ondismiss={dismissLinkWarning}
onupdate={(remaining) => {
if (!linkWarning) return;
if (remaining.length === 0) {
linkWarning = null;
doSave();
} else {
linkWarning = { ...linkWarning, albums: remaining };
}
}}
/>
<ConfirmModal
@@ -1,17 +1,50 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { api } from '$lib/api';
import { snackError, snackSuccess } from '$lib/stores/snackbar.svelte';
import Modal from '$lib/components/Modal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
interface AlbumIssue { id: string; name: string; issue: string }
interface Props {
linkWarning: { albums: any[]; providerId: number } | null;
linkWarning: { albums: AlbumIssue[]; providerId: number } | null;
linkCreating: boolean;
onclose: () => void;
onautoCreate: () => void;
ondismiss: () => void;
/** Called with the updated warning list after a per-row replace. */
onupdate?: (albums: AlbumIssue[]) => void;
}
let { linkWarning, linkCreating, onclose, onautoCreate, ondismiss }: Props = $props();
let { linkWarning, linkCreating, onclose, onautoCreate, ondismiss, onupdate }: Props = $props();
/** Per-row loading state for the "Replace" button. */
let replacing = $state<Record<string, boolean>>({});
/**
* Expired and password-protected links can't be repaired in place — the
* Immich API has no "reset" endpoint. The only remedy is to recreate the
* link (which the backend does by POSTing a new one and returning it).
* We surface the action per-row so users don't have to leave the form.
*/
async function replaceOne(album: AlbumIssue) {
if (!linkWarning) return;
replacing = { ...replacing, [album.id]: true };
try {
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, {
method: 'POST',
body: JSON.stringify({ replace: true }),
});
snackSuccess(t('notificationTracker.createdLinks').replace('{count}', '1'));
const remaining = linkWarning.albums.filter(a => a.id !== album.id);
if (onupdate) onupdate(remaining);
} catch (err: any) {
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + err.message);
} finally {
replacing = { ...replacing, [album.id]: false };
}
}
</script>
<Modal open={linkWarning !== null} title={t('notificationTracker.missingLinksTitle')} onclose={onclose}>
@@ -19,13 +52,26 @@
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
{t('notificationTracker.missingLinksDesc')}
</p>
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto">
<div class="space-y-1.5 mb-4 max-h-60 overflow-y-auto">
{#each linkWarning.albums as album}
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
<span class="font-medium">{album.name}</span>
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
<div class="flex items-center justify-between gap-2 text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
<div class="flex-1 min-w-0">
<span class="font-medium truncate block">{album.name}</span>
{#if album.issue === 'password-protected'}
<span class="text-[10px] block" style="color: var(--color-muted-foreground);">
{t('notificationTracker.linkPasswordProtectedNote')}
</span>
{/if}
</div>
<span class="text-xs px-1.5 py-0.5 rounded shrink-0 {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')}
</span>
{#if album.issue === 'expired' || album.issue === 'password-protected'}
<button type="button" onclick={() => replaceOne(album)} disabled={replacing[album.id]}
class="text-xs px-2 py-1 rounded border border-[var(--color-border)] hover:bg-[var(--color-muted)] disabled:opacity-50 shrink-0">
{replacing[album.id] ? t('notificationTracker.linkReplacing') : t('notificationTracker.linkReplace')}
</button>
{/if}
</div>
{/each}
</div>
@@ -6,7 +6,13 @@
testMenuOpen: string | null;
testMenuStyle: string;
ttTesting: Record<string, string>;
testTypes: { key: string; icon: string; labelKey: string }[];
/**
* When `disabledReason` is set, the button is rendered greyed out with a
* tooltip pointing the user at the missing setting (e.g. "Enable Periodic
* Summary in Tracking Config first"). Clicking is blocked — clicking an
* unconfigured test would have surfaced as a silent server-side skip.
*/
testTypes: { key: string; icon: string; labelKey: string; disabledReason?: string }[];
ontest: (ttId: number, testType: string) => void;
onclose: () => void;
}
@@ -20,18 +26,27 @@
onclick={onclose}
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}>
</div>
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:10rem;">
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:12rem;">
{#each testTypes as tt}
{@const busy = !!ttTesting[`${testMenuOpen}_${tt.key}`]}
{@const blocked = !!tt.disabledReason}
<button
onclick={() => ontest(Number(testMenuOpen), tt.key)}
disabled={!!ttTesting[`${testMenuOpen}_${tt.key}`]}
onclick={() => { if (!blocked) ontest(Number(testMenuOpen), tt.key); }}
disabled={busy || blocked}
title={blocked ? t(tt.disabledReason!) : ''}
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm rounded hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 text-left">
<MdiIcon name={tt.icon} size={14} />
{t(tt.labelKey)}
{#if ttTesting[`${testMenuOpen}_${tt.key}`]}
{#if blocked}
<MdiIcon name="mdiLock" size={12} />
{/if}
{#if busy}
<span class="ml-auto text-xs text-[var(--color-muted-foreground)]">...</span>
{/if}
</button>
{#if blocked}
<p class="px-3 pb-1 text-[10px]" style="color: var(--color-muted-foreground);">{t(tt.disabledReason!)}</p>
{/if}
{/each}
</div>
{/if}
@@ -4,6 +4,7 @@
import Card from '$lib/components/Card.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import Hint from '$lib/components/Hint.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
import { getDescriptor } from '$lib/providers';
@@ -199,6 +200,22 @@
</div>
{/if}
<!-- Feature discovery: the periodic/scheduled/memory/quiet-hours controls
live on the tracking config, not on the tracker itself. Surface this
here so users don't have to stumble onto the feature by reading docs. -->
{#if providerType === 'immich'}
<div class="flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-muted)]/30 px-3 py-2">
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
<div class="flex-1 text-xs">
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
<a href="/tracking-configs" class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline mt-1">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTrackingConfig')}
</a>
</div>
</div>
{/if}
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
</button>
@@ -42,6 +42,17 @@
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
/**
* Reset-to-default confirmation prompt. ``kind: 'slot'`` confirms a
* single-slot reset (slotKey populated); ``'all'`` confirms a full
* locale-scoped wipe. Split from confirmDelete so the two flows can
* coexist without stomping each other's state mid-dialog.
*/
let confirmReset = $state<{
kind: 'slot' | 'all';
slotKey?: string;
message: string;
} | null>(null);
let slotPreview = $state<Record<string, string>>({});
let slotErrors = $state<Record<string, string>>({});
let slotErrorLines = $state<Record<string, number | null>>({});
@@ -206,7 +217,40 @@
supportedLocalesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; highlightFromUrl(); }
finally { loaded = true; highlightFromUrl(); handleDeepLink(); }
}
/**
* Respond to ``?edit_slot=<slot_name>&provider=<type>`` deep-links from
* other pages (currently the tracking-configs Preview-template modal).
* Picks the first visible config matching ``provider``, opens it in edit
* mode, and pre-expands the target slot. Strips the param from the URL so
* a subsequent reload doesn't reopen the form unexpectedly.
*/
function handleDeepLink() {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const slot = params.get('edit_slot');
if (!slot) return;
const provider = params.get('provider') || '';
const target = allTemplateConfigs.find(
c => !provider || c.provider_type === provider,
);
// Strip the deep-link param so reload/back doesn't replay it.
params.delete('edit_slot');
const qs = params.toString();
window.history.replaceState(null, '', window.location.pathname + (qs ? '?' + qs : ''));
if (!target) {
snackError(t('templateConfig.deepLinkNoConfig'));
return;
}
edit(target);
expandedSlots = new Set([slot]);
// Scroll the slot into view once the form has rendered.
setTimeout(() => {
const el = document.getElementById(`slot-${slot}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 200);
}
function openNew() {
@@ -241,6 +285,65 @@
} catch (err: any) { error = err.message; snackError(err.message); }
}
/**
* Ask the user to confirm a reset. The actual fetch+replace runs in
* ``performReset`` after the ConfirmModal's onconfirm fires. Split into
* two steps so we can use the app-wide ConfirmModal (consistent look,
* keyboard handling) instead of ``window.confirm`` (blocks the page).
*/
function resetSlotToDefault(slotKey: string) {
if (!form.provider_type) return;
confirmReset = {
kind: 'slot',
slotKey,
message: t('templateConfig.resetSlotConfirm').replace('{locale}', activeLocale.toUpperCase()),
};
}
function resetAllToDefaults() {
if (!form.provider_type) return;
confirmReset = {
kind: 'all',
message: t('templateConfig.resetAllConfirm').replace(/\{locale\}/g, activeLocale.toUpperCase()),
};
}
async function performReset() {
if (!confirmReset || !form.provider_type) return;
const { kind, slotKey } = confirmReset;
confirmReset = null;
try {
if (kind === 'slot' && slotKey) {
const res = await api<Record<string, Record<string, string>>>(
`/template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&slot_name=${encodeURIComponent(slotKey)}&locale=${encodeURIComponent(activeLocale)}`,
);
const text = res?.[slotKey]?.[activeLocale];
if (!text) {
snackError(t('templateConfig.resetNoDefault'));
return;
}
setSlotValue(slotKey, text);
validateSlot(slotKey, text, true);
} else {
const res = await api<Record<string, Record<string, string>>>(
`/template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&locale=${encodeURIComponent(activeLocale)}`,
);
// Replace current-locale slots; leave other locales' values untouched.
const nextSlots = { ...form.slots };
for (const [key, localeMap] of Object.entries(res || {})) {
const text = localeMap?.[activeLocale];
if (text === undefined) continue;
nextSlots[key] = { ...(nextSlots[key] || {}), [activeLocale]: text };
}
form.slots = nextSlots;
refreshAllPreviews();
}
snackSuccess(t('templateConfig.resetApplied'));
} catch (err: any) {
snackError(err.message);
}
}
function clone(c: TemplateConfig) {
form = {
provider_type: c.provider_type,
@@ -321,7 +424,7 @@
</div>
<!-- Locale tabs -->
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]">
{#each LOCALES as loc}
<button type="button"
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
@@ -329,6 +432,14 @@
{loc.toUpperCase()}
</button>
{/each}
{#if form.provider_type}
<button type="button" onclick={resetAllToDefaults}
title={t('templateConfig.resetAllToDefaults')}
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
<MdiIcon name="mdiRefresh" size={12} />
{t('templateConfig.resetAllToDefaults')}
</button>
{/if}
</div>
<!-- Slot filter -->
@@ -361,6 +472,7 @@
{/if}
</div>
{:else}
<div id="slot-{slot.key}">
<CollapsibleSlot
label={slot.key}
description={slot.description || t(`templateConfig.${slot.label}`, slot.label)}
@@ -379,6 +491,11 @@
<button type="button" onclick={() => showVarsFor = slot.key}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
<button type="button" onclick={() => resetSlotToDefault(slot.key)}
title={t('templateConfig.resetToDefault')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{t('templateConfig.resetToDefault')}
</button>
</div>
{#if showPreviewFor.has(slot.key) && slotPreview[slot.key] && !slotErrors[slot.key]}
@@ -397,6 +514,7 @@
{/if}
{/if}
</CollapsibleSlot>
</div>
{/if}
{/each}
</div>
@@ -466,6 +584,14 @@
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<ConfirmModal open={confirmReset !== null}
title={t('templateConfig.resetToDefault')}
message={confirmReset?.message || ''}
confirmLabel={confirmReset?.kind === 'all' ? t('templateConfig.resetAllToDefaults') : t('templateConfig.resetToDefault')}
confirmIcon="mdiRefresh"
onconfirm={performReset}
oncancel={() => confirmReset = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal -->
+232 -11
View File
@@ -12,6 +12,9 @@
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import Modal from '$lib/components/Modal.svelte';
import { sanitizePreview } from '$lib/sanitize';
import { supportedLocalesCache } from '$lib/stores/caches.svelte';
import Hint from '$lib/components/Hint.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
@@ -22,13 +25,150 @@
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import type { TrackingConfig } from '$lib/types';
/** Grid-select item source lookup — maps descriptor string name to actual function. */
const gridItemSources: Record<string, () => any[]> = {
sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems,
};
/**
* HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron
* dispatch accepts. Matched on blur for time-list fields; invalid values
* are surfaced inline next to the input.
*/
const TIME_LIST_RE = /^\s*(?:[01]\d|2[0-3]):[0-5]\d(?:\s*,\s*(?:[01]\d|2[0-3]):[0-5]\d)*\s*$/;
/** Per-field error messages surfaced inline under time-list inputs. */
let timeListErrors = $state<Record<string, string>>({});
/** Normalize "9:0 , 18:30" → "09:00,18:30" on blur, clear error when valid. */
function normalizeTimeList(key: string) {
const raw = String(form[key] ?? '').trim();
if (!raw) { timeListErrors = { ...timeListErrors, [key]: '' }; return; }
if (!TIME_LIST_RE.test(raw)) {
// Try a lenient normalization: split on commas, zero-pad each part.
const parts = raw.split(',').map(p => p.trim()).filter(Boolean);
const fixed: string[] = [];
let ok = true;
for (const p of parts) {
const m = /^(\d{1,2}):(\d{1,2})$/.exec(p);
if (!m) { ok = false; break; }
const hh = Number(m[1]);
const mm = Number(m[2]);
if (!Number.isFinite(hh) || !Number.isFinite(mm) || hh < 0 || hh > 23 || mm < 0 || mm > 59) { ok = false; break; }
fixed.push(`${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`);
}
if (ok) {
form[key] = fixed.join(',');
timeListErrors = { ...timeListErrors, [key]: '' };
return;
}
timeListErrors = { ...timeListErrors, [key]: t('trackingConfig.invalidTimeList') };
return;
}
// Canonicalise spacing.
form[key] = raw.split(',').map(s => s.trim()).join(',');
timeListErrors = { ...timeListErrors, [key]: '' };
}
/**
* Quiet-hours preview: "22:00 → 07:00 next day (9h)" or "Quiet period is 0
* minutes — adjust times" when start equals end. Handles overnight ranges
* (start > end) correctly.
*/
function quietHoursPreview(start: string, end: string): string {
if (!start || !end) return '';
const [sh, sm] = start.split(':').map(Number);
const [eh, em] = end.split(':').map(Number);
if (![sh, sm, eh, em].every(Number.isFinite)) return '';
const sMin = sh * 60 + sm;
const eMin = eh * 60 + em;
if (sMin === eMin) return t('trackingConfig.quietHoursZero');
const overnight = sMin > eMin;
const span = overnight ? (24 * 60 - sMin) + eMin : eMin - sMin;
const h = Math.floor(span / 60);
const m = span % 60;
const dur = m === 0 ? `${h}h` : `${h}h ${m}m`;
const arrow = overnight
? `${start}${end} ${t('trackingConfig.nextDay')}`
: `${start}${end}`;
return `${arrow} (${dur})`;
}
function gotoTemplateConfig(slotName: string) {
// Deep-link to the template configs page: pass the slot as a query
// param (``edit_slot``) so the destination can auto-open the first
// matching config in edit mode and expand that slot. Plain hashes
// like ``#slot-X`` were a no-op because slots don't exist in the DOM
// until a config is being edited.
const u = new URL('/template-configs', window.location.origin);
u.searchParams.set('provider', 'immich');
u.searchParams.set('edit_slot', slotName);
window.location.href = u.toString();
}
/**
* Inline preview of the shipped default template for a scheduled/periodic/
* memory slot. Using the shipped default (not a tracker's current template)
* keeps this scoped to the tracking-config page — which has no concept of
* which TemplateConfig a given tracker uses. Users who want to edit the
* actual config can click "Edit template" in the modal footer.
*
* ``previewLocale`` is modal-scoped so switching tabs only refetches for
* this preview — the user's UI locale (and other previews) are untouched.
*/
let previewModal = $state<{ slotName: string; rendered: string; error: string; locale: string } | null>(null);
let previewLoading = $state(false);
let previewLocales = $derived(supportedLocalesCache.items);
async function openTemplatePreview(slotName: string) {
await supportedLocalesCache.fetch();
const initialLocale = previewLocales.includes('en') ? 'en' : (previewLocales[0] || 'en');
await renderPreviewFor(slotName, initialLocale);
}
async function renderPreviewFor(slotName: string, locale: string) {
previewLoading = true;
try {
const defaults = await api<Record<string, Record<string, string>>>(
`/template-configs/defaults?provider_type=immich&slot_name=${encodeURIComponent(slotName)}&locale=${encodeURIComponent(locale)}`,
);
const template = defaults?.[slotName]?.[locale];
if (!template) {
previewModal = { slotName, rendered: '', error: t('templateConfig.resetNoDefault'), locale };
return;
}
const res = await api<{ rendered?: string; error?: string }>(
'/template-configs/preview-raw',
{
method: 'POST',
body: JSON.stringify({
template,
target_type: 'telegram',
date_format: '%d.%m.%Y, %H:%M UTC',
date_only_format: '%d.%m.%Y',
}),
},
);
previewModal = {
slotName,
rendered: res?.rendered || '',
error: res?.error || '',
locale,
};
} catch (err: any) {
previewModal = { slotName, rendered: '', error: err.message, locale };
} finally {
previewLoading = false;
}
}
const SLOT_FOR_SECTION: Record<string, string> = {
periodic: 'periodic_summary_message',
scheduled: 'scheduled_assets_message',
memory: 'memory_mode_message',
};
let allConfigs = $derived(trackingConfigsCache.items);
let filterText = $state('');
let filterType = $state('');
@@ -161,10 +301,20 @@
{t(section.legend)}
{#if section.legendHint}<Hint text={t(section.legendHint)} />{/if}
</legend>
<label class="flex items-center gap-2 text-sm mt-1">
<input type="checkbox" bind:checked={form[section.enabledField]} />
{t('trackingConfig.enabled')}
</label>
<div class="flex items-center justify-between mt-1">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={form[section.enabledField]} />
{t('trackingConfig.enabled')}
</label>
{#if SLOT_FOR_SECTION[section.key]}
<button type="button" onclick={() => openTemplatePreview(SLOT_FOR_SECTION[section.key])}
class="text-xs text-[var(--color-primary)] hover:underline inline-flex items-center gap-1"
disabled={previewLoading}>
<MdiIcon name="mdiEyeOutline" size={14} />
{t('trackingConfig.previewTemplate')}
</button>
{/if}
</div>
{#if form[section.enabledField]}
<div class="grid grid-cols-3 gap-3 mt-3">
{#each section.fields as field (field.key)}
@@ -181,17 +331,32 @@
{:else if field.type === 'grid-select' && field.gridItems}
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
{:else}
<input type={field.key.includes('date') ? 'date'
: field.key.startsWith('quiet_hours_') ? 'time'
: field.key.includes('times') ? 'text'
: 'number'}
{@const inputType = field.type === 'date' ? 'date'
: field.type === 'time' ? 'time'
: field.type === 'time-list' ? 'text'
: 'number'}
{@const hasError = field.type === 'time-list' && !!timeListErrors[field.key]}
<input type={inputType}
bind:value={form[field.key]} min={field.min} max={field.max}
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)]" />
onblur={field.type === 'time-list' && field.validateFormat ? () => normalizeTimeList(field.key) : undefined}
placeholder={field.type === 'time-list' || field.type === 'time' ? String(typeof field.defaultValue === 'function' ? field.defaultValue() : (field.defaultValue ?? '')) : ''}
class="w-full px-2 py-1 border rounded-md text-sm bg-[var(--color-background)] {hasError ? 'border-[var(--color-error-fg)]' : 'border-[var(--color-border)]'}" />
{#if field.inlineHelp}
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
{/if}
{#if hasError}
<p class="text-[10px] mt-0.5" style="color: var(--color-error-fg);">{timeListErrors[field.key]}</p>
{/if}
{/if}
</div>
{/each}
</div>
{#if section.key === 'quietHours' && form.quiet_hours_start && form.quiet_hours_end}
<p class="text-xs mt-2" style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiWeatherNight" size={12} />
{quietHoursPreview(String(form.quiet_hours_start), String(form.quiet_hours_end))}
</p>
{/if}
{/if}
</fieldset>
{/each}
@@ -268,7 +433,63 @@
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<Modal open={previewModal !== null}
title={previewModal ? `${t('trackingConfig.previewTemplate')} ${previewModal.slotName}` : ''}
onclose={() => previewModal = null}>
{#if previewModal}
{#if previewLocales.length > 1}
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
{#each previewLocales as loc}
<button type="button"
onclick={() => renderPreviewFor(previewModal!.slotName, loc)}
disabled={previewLoading}
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {previewModal.locale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'} disabled:opacity-50">
{loc.toUpperCase()}
</button>
{/each}
</div>
{/if}
<p class="text-xs mb-3" style="color: var(--color-muted-foreground);">
{t('trackingConfig.previewSampleNote')}
</p>
<!-- Keep the prior rendered/error box mounted while refetching on locale
switch — just dim it. Unmounting and replacing with a small "…"
placeholder caused a one-frame layout jump as the modal shrank and
then re-expanded. -->
<div class="relative mb-3" style="opacity: {previewLoading ? 0.5 : 1}; transition: opacity 0.15s ease;">
{#if previewModal.error}
<div class="p-3 rounded text-xs" style="background: var(--color-error-bg); color: var(--color-error-fg);">
{previewModal.error}
</div>
{:else if previewModal.rendered}
<div class="p-3 bg-[var(--color-muted)] rounded text-sm preview-html">
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(previewModal.rendered)}</pre>
</div>
{:else}
<div class="p-3 text-xs" style="color: var(--color-muted-foreground);"></div>
{/if}
</div>
<div class="flex gap-2 justify-end mt-3">
<button type="button" onclick={() => { const s = previewModal!.slotName; previewModal = null; gotoTemplateConfig(s); }}
class="text-xs px-3 py-1.5 rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)]">
{t('trackingConfig.editTemplate')}
</button>
<button type="button" onclick={() => previewModal = null}
class="text-xs px-3 py-1.5 rounded-md bg-[var(--color-primary)] text-[var(--color-primary-foreground)]">
{t('common.close')}
</button>
</div>
{/if}
</Modal>
<style>
:global(.preview-html a) {
color: var(--color-primary);
text-decoration: underline;
}
:global(.preview-html a:hover) {
opacity: 0.8;
}
.toggle-switch {
position: relative;
display: inline-flex;
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-core"
version = "0.4.0"
version = "0.5.1"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12"
dependencies = [
@@ -105,7 +105,7 @@ class NotificationDispatcher:
if self._shared_session is not None and not self._shared_session.closed:
yield self._shared_session
return
async with self._session_ctx() as session:
async with _new_session() as session:
yield session
async def dispatch(
@@ -333,8 +333,11 @@ def collect_scheduled_assets(
memory_date = now.isoformat() if is_memory else None
all_eligible: list[ImmichAssetInfo] = []
# Track which album each asset belongs to for public URL construction
asset_album_map: dict[str, tuple[str, str]] = {} # asset_id → (album_id, public_url)
# Track which album each asset belongs to. Public URL is used to construct
# a per-asset share link; name/internal-url are surfaced to templates so
# combined-mode sends can attribute each row to its source album.
asset_album_map: dict[str, tuple[str, str, str, str]] = {}
# asset_id → (album_id, album_public_url, album_name, album_internal_url)
collections_extra: list[dict[str, Any]] = []
# limit=0 is the periodic-summary test path — the caller only needs
@@ -346,10 +349,11 @@ def collect_scheduled_assets(
for album_id, album in albums.items():
links = shared_links.get(album_id, [])
album_public_url = get_public_url(external_url, links) or ""
album_internal_url = f"{external_url}/albums/{album_id}"
collections_extra.append({
"name": album.name,
"url": album_public_url or f"{external_url}/albums/{album_id}",
"url": album_public_url or album_internal_url,
"public_url": album_public_url,
"asset_count": album.asset_count,
"shared": album.shared,
@@ -370,7 +374,9 @@ def collect_scheduled_assets(
)
for asset in filtered:
if asset.id not in asset_album_map:
asset_album_map[asset.id] = (album_id, album_public_url)
asset_album_map[asset.id] = (
album_id, album_public_url, album.name, album_internal_url,
)
all_eligible.append(asset)
if stats_only:
@@ -383,15 +389,25 @@ def collect_scheduled_assets(
random.shuffle(all_eligible)
selected = all_eligible
# Convert to MediaAsset with public URLs
# Convert to MediaAsset with public URLs. Per-asset album_name/album_url
# let combined-mode templates attribute each row to its source album —
# critical when a tracker spans multiple albums, where the event-level
# ``album_name`` (first album only) would be misleading.
result: list[MediaAsset] = []
for asset in selected:
media = asset_to_media(asset, external_url)
_, album_pub_url = asset_album_map.get(asset.id, ("", ""))
mapped = asset_album_map.get(asset.id)
if mapped:
_, album_pub_url, album_name, album_internal_url = mapped
else:
album_pub_url = album_name = album_internal_url = ""
if album_pub_url:
media.extra["public_url"] = f"{album_pub_url}/photos/{asset.id}"
else:
media.extra.setdefault("public_url", "")
media.extra["album_name"] = album_name
media.extra["album_url"] = album_pub_url or album_internal_url
media.extra["album_public_url"] = album_pub_url
result.append(media)
return result, collections_extra
@@ -1,5 +1,5 @@
⭐ Favorites:
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %} ❤️
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ❤️
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- endfor %}
@@ -1,6 +1,6 @@
📸 Latest:
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
@@ -1,5 +1,6 @@
📅 On this day:
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
@@ -1,6 +1,6 @@
🎲 Random:
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
@@ -4,7 +4,7 @@
{%- else %}🔍 Results for "{{ query }}":
{%- endif %}
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
@@ -1,4 +1,6 @@
📋 Album summary ({{ albums | length }}):
{%- for album in albums %}
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} assets
{%- endfor %}
{%- if album.photo_count is defined or album.video_count is defined %} ({% if album.photo_count %}🖼️ {{ album.photo_count }}{% endif %}{% if album.photo_count and album.video_count %} · {% endif %}{% if album.video_count %}🎬 {{ album.video_count }}{% endif %}){% endif %}
{%- if album.shared %} 🔗{% endif %}
{%- endfor %}
@@ -1,5 +1,5 @@
⭐ Избранное:
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %} ❤️
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ❤️
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- endfor %}
@@ -1,6 +1,6 @@
📸 Последние:
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
@@ -1,5 +1,6 @@
📅 В этот день:
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
@@ -1,6 +1,6 @@
🎲 Случайные:
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
@@ -4,7 +4,7 @@
{%- else %}🔍 Результаты по "{{ query }}":
{%- endif %}
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
@@ -1,4 +1,6 @@
📋 Сводка альбомов ({{ albums | length }}):
{%- for album in albums %}
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} файлов
{%- endfor %}
{%- if album.photo_count is defined or album.video_count is defined %} ({% if album.photo_count %}🖼️ {{ album.photo_count }}{% endif %}{% if album.photo_count and album.video_count %} · {% endif %}{% if album.video_count %}🎬 {{ album.video_count }}{% endif %}){% endif %}
{%- if album.shared %} 🔗{% endif %}
{%- endfor %}
@@ -1,4 +1,7 @@
📅 On this day:
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ({{ asset.created_at[:4] }})
{%- endfor %}
{%- if albums and albums|length > 1 and asset.album_name %} — {% if asset.album_url %}<a href="{{ asset.album_url }}">{{ asset.album_name }}</a>{% else %}{{ asset.album_name }}{% endif %}{% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
@@ -1,4 +1,6 @@
📋 Tracked Albums Summary ({{ albums | length }} albums):
{%- for album in albums %}
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} assets
{%- endfor %}
{%- if album.photo_count is defined or album.video_count is defined %} ({% if album.photo_count %}🖼️ {{ album.photo_count }}{% endif %}{% if album.photo_count and album.video_count %} · {% endif %}{% if album.video_count %}🎬 {{ album.video_count }}{% endif %}){% endif %}
{%- if album.shared %} 🔗{% endif %}
{%- endfor %}
@@ -1,4 +1,11 @@
📸 Photos from {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
{%- if albums and albums|length > 1 -%}
🗓️ Scheduled delivery — random photos from {% for album in albums %}{% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}:
{%- else -%}
🗓️ Scheduled delivery — random photos from {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
{%- endif %}
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
{%- endfor %}
{%- if albums and albums|length > 1 and asset.album_name %} — {% if asset.album_url %}<a href="{{ asset.album_url }}">{{ asset.album_name }}</a>{% else %}{{ asset.album_name }}{% endif %}{% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
@@ -1,4 +1,7 @@
📅 В этот день:
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ({{ asset.created_at[:4] }})
{%- endfor %}
{%- if albums and albums|length > 1 and asset.album_name %} — {% if asset.album_url %}<a href="{{ asset.album_url }}">{{ asset.album_name }}</a>{% else %}{{ asset.album_name }}{% endif %}{% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
@@ -1,4 +1,6 @@
📋 Сводка альбомов ({{ albums | length }}):
{%- for album in albums %}
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} файлов
{%- endfor %}
{%- if album.photo_count is defined or album.video_count is defined %} ({% if album.photo_count %}🖼️ {{ album.photo_count }}{% endif %}{% if album.photo_count and album.video_count %} · {% endif %}{% if album.video_count %}🎬 {{ album.video_count }}{% endif %}){% endif %}
{%- if album.shared %} 🔗{% endif %}
{%- endfor %}
@@ -1,4 +1,11 @@
📸 Фото из {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
{%- if albums and albums|length > 1 -%}
🗓️ Доставка по расписанию — случайные фото из {% for album in albums %}{% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}:
{%- else -%}
🗓️ Доставка по расписанию — случайные фото из {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
{%- endif %}
{%- for asset in assets %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
{%- endfor %}
{%- if albums and albums|length > 1 and asset.album_name %} — {% if asset.album_url %}<a href="{{ asset.album_url }}">{{ asset.album_name }}</a>{% else %}{{ asset.album_name }}{% endif %}{% endif %}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-server"
version = "0.4.0"
version = "0.5.1"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12"
dependencies = [
@@ -74,6 +74,36 @@ async def _get(session: AsyncSession, config_id: int, user_id: int) -> CommandTe
# Routes
# ---------------------------------------------------------------------------
@router.get("/defaults")
async def get_default_command_templates(
provider_type: str,
slot_name: str | None = None,
locale: str | None = None,
user: User = Depends(get_current_user),
):
"""Return the shipped Jinja2 default command templates for a provider type.
Used by the UI's "Reset to default" actions. Filtering works the same way
as the notification-template equivalent: omit ``slot_name`` for the whole
set, omit ``locale`` for every locale.
Response shape: ``{slot_name: {locale: template_text}}``
"""
from notify_bridge_core.templates.command_defaults.loader import (
load_default_command_templates,
)
from notify_bridge_core.templates.defaults.loader import get_available_locales
locales = [locale] if locale else get_available_locales()
result: dict[str, dict[str, str]] = {}
for loc in locales:
defaults = load_default_command_templates(loc, provider_type)
for name, text in defaults.items():
if slot_name and name != slot_name:
continue
result.setdefault(name, {})[loc] = text
return result
@router.get("/variables")
async def get_command_variables(
user: User = Depends(get_current_user),
@@ -84,15 +114,26 @@ async def get_command_variables(
}
asset_fields = {
"id": "Asset ID (UUID)",
"originalFileName": "Original filename",
"filename": "Original filename (preferred; same as originalFileName)",
"originalFileName": "Original filename (alias of filename, kept for backward-compat with older templates)",
"type": "IMAGE or VIDEO",
"createdAt": "Creation date/time (ISO 8601)",
"created_at": "Creation date/time (ISO 8601)",
"createdAt": "Creation date/time (alias of created_at)",
"year": "Year of the memory (memory command only)",
"public_url": "Per-asset public share URL (empty if no album link)",
"city": "City name (empty if unknown)",
"country": "Country name (empty if unknown)",
"is_favorite": "Whether asset is favorited (boolean)",
}
album_fields = {
"name": "Album name",
"asset_count": "Number of assets in the album",
"id": "Album ID (UUID)",
"public_url": "Public share link URL (empty if none)",
"asset_count": "Number of assets in the album",
"photo_count": "Number of photos in the album",
"video_count": "Number of videos in the album",
"shared": "Whether the album is shared (boolean)",
"owner": "Album owner display name",
}
command_fields = {
"name": "Command name (e.g. status, albums)",
@@ -492,10 +533,11 @@ async def preview_raw(
{"name": "latest", "description": "Show latest photos", "usage": "/latest 10"},
{"name": "search", "description": "Smart search (AI)", "usage": "/search sunset at the beach"},
],
# /albums, /summary
# /albums, /summary — provide photo/video split, sharing, owner so the
# enriched summary template previews fully.
"albums": [
{"name": "Family Photos", "asset_count": 142, "id": "abc-123", "public_url": "https://immich.example.com/share/abc123"},
{"name": "Vacation 2025", "asset_count": 87, "id": "def-456", "public_url": ""},
{"name": "Family Photos", "asset_count": 142, "photo_count": 120, "video_count": 22, "shared": True, "owner": "Alice", "id": "abc-123", "public_url": "https://immich.example.com/share/abc123"},
{"name": "Vacation 2025", "asset_count": 87, "photo_count": 80, "video_count": 7, "shared": False, "owner": "Bob", "id": "def-456", "public_url": ""},
],
# /events
"events": [
@@ -505,9 +547,12 @@ async def preview_raw(
# /people
"people": ["Alice", "Bob", "Charlie"],
# /search, /find, /person, /place, /latest, /favorites, /random, /memory
# ``filename`` is the canonical key (matches notification context and
# build_asset_dict output); ``originalFileName`` is kept as an alias
# so templates still using the old key render in preview.
"assets": [
{"id": "a1", "originalFileName": "IMG_001.jpg", "type": "IMAGE", "createdAt": "2026-03-19T14:30:00", "year": 2024, "public_url": "https://immich.example.com/share/abc123/photos/a1", "city": "Paris", "country": "France", "is_favorite": True},
{"id": "a2", "originalFileName": "VID_002.mp4", "type": "VIDEO", "createdAt": "2026-03-19T15:00:00", "year": 2023, "public_url": "", "city": "", "country": "", "is_favorite": False},
{"id": "a1", "filename": "IMG_001.jpg", "originalFileName": "IMG_001.jpg", "type": "IMAGE", "created_at": "2026-03-19T14:30:00", "createdAt": "2026-03-19T14:30:00", "year": 2024, "public_url": "https://immich.example.com/share/abc123/photos/a1", "city": "Paris", "country": "France", "is_favorite": True},
{"id": "a2", "filename": "VID_002.mp4", "originalFileName": "VID_002.mp4", "type": "VIDEO", "created_at": "2026-03-19T15:00:00", "createdAt": "2026-03-19T15:00:00", "year": 2023, "public_url": "", "city": "", "country": "", "is_favorite": False},
],
"query": "sunset",
"command": "search",
@@ -23,6 +23,7 @@ from ..database.models import (
)
from ..services.notifier import send_test_notification
from ..services.manual_dispatch import dispatch_test_notification
from ..services.scheduler import reschedule_immich_dispatch_jobs
from .helpers import get_owned_entity
_LOGGER = logging.getLogger(__name__)
@@ -118,6 +119,7 @@ async def create_notification_tracker_target(
session.add(tt)
await session.commit()
await session.refresh(tt)
await reschedule_immich_dispatch_jobs()
return await _tt_response(session, tt)
@@ -164,6 +166,7 @@ async def update_notification_tracker_target(
session.add(tt)
await session.commit()
await session.refresh(tt)
await reschedule_immich_dispatch_jobs()
return await _tt_response(session, tt)
@@ -181,6 +184,7 @@ async def delete_notification_tracker_target(
raise HTTPException(status_code=404, detail="Tracker-target link not found")
await session.delete(tt)
await session.commit()
await reschedule_immich_dispatch_jobs()
@router.post("/{tracker_target_id}/test/{test_type}")
@@ -18,7 +18,11 @@ from ..database.models import (
ServiceProvider,
User,
)
from ..services.scheduler import schedule_tracker, unschedule_tracker
from ..services.scheduler import (
reschedule_immich_dispatch_jobs,
schedule_tracker,
unschedule_tracker,
)
from .helpers import get_owned_entity
from .notification_tracker_targets import _tt_response
@@ -146,6 +150,7 @@ async def create_notification_tracker(
await session.refresh(tracker)
if tracker.enabled:
await schedule_tracker(tracker.id, tracker.scan_interval)
await reschedule_immich_dispatch_jobs()
return await _tracker_response(session, tracker)
@@ -176,6 +181,7 @@ async def update_notification_tracker(
await schedule_tracker(tracker.id, tracker.scan_interval)
else:
await unschedule_tracker(tracker.id)
await reschedule_immich_dispatch_jobs()
return await _tracker_response(session, tracker)
@@ -208,6 +214,7 @@ async def delete_notification_tracker(
await session.delete(tracker)
await session.commit()
await unschedule_tracker(tracker_id)
await reschedule_immich_dispatch_jobs()
@router.post("/{tracker_id}/trigger")
@@ -426,19 +426,45 @@ async def get_album_shared_links(
return []
class CreateSharedLinkRequest(BaseModel):
"""Options for POST /shared-links.
``replace=True`` deletes every existing link for the album before creating
the new one, which is the only way to repair an expired or password-
protected link in the Immich API (there is no in-place "reset" endpoint).
Default ``False`` preserves the original additive behaviour used by the
"auto-create missing links" flow.
"""
replace: bool = False
@router.post("/{provider_id}/albums/{album_id}/shared-links")
async def create_album_shared_link(
provider_id: int,
album_id: str,
body: CreateSharedLinkRequest | None = None,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Auto-create a public shared link for an album."""
"""Auto-create a public shared link for an album.
With ``replace=True`` existing links for the album are deleted first, so
expired/password-protected links are effectively recycled into a fresh
public one.
"""
provider = await _get_user_provider(session, provider_id, user.id)
if provider.type == "immich":
http_session = await get_http_session()
immich = make_immich_provider(http_session, provider)
if body and body.replace:
# Best-effort delete; if any delete fails we still try to create —
# the user will see the new link co-exist alongside the old one,
# which is better than a hard failure that leaves them stuck.
existing = await immich.client.get_shared_links(album_id)
for link in existing:
await immich.client.delete_shared_link(link.id)
success = await immich.client.create_shared_link(album_id)
if success:
return {"success": True}
@@ -86,6 +86,11 @@ async def update_bot(
bot.icon = body.icon
# Handle mode switching
if body.update_mode is not None and body.update_mode != bot.update_mode:
if body.update_mode not in ("none", "polling", "webhook"):
raise HTTPException(
status_code=400,
detail=f"Invalid update_mode: {body.update_mode!r}. Must be 'none', 'polling', or 'webhook'.",
)
if body.update_mode == "webhook":
# Validate and register webhook BEFORE stopping polling
base_url = await get_setting(session, "external_url")
@@ -108,6 +113,12 @@ async def update_bot(
# Switching to polling: unregister webhook, start polling
await unregister_webhook(bot.token)
schedule_bot_polling(bot.id)
elif body.update_mode == "none":
# Disable listener: stop polling and clear any webhook so Telegram
# stops delivering updates. This makes the bot send-only, which is
# safe when another instance owns the listener.
unschedule_bot_polling(bot.id)
await unregister_webhook(bot.token)
bot.update_mode = body.update_mode
session.add(bot)
@@ -287,10 +298,30 @@ async def test_chat(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test message to a chat via the bot."""
"""Send a test message to a chat via the bot.
Locale resolution: prefer the chat row's ``language_override`` (explicit
user choice in the UI), fall back to Telegram's ``language_code`` sent
with the chat, and only use the ``?locale=`` query param if neither is
set. Otherwise users who set RU on a chat would still see an EN test.
"""
bot = await _get_user_bot(session, bot_id, user.id)
chat_row = (await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id == chat_id,
)
)).first()
effective_locale = locale
if chat_row:
chat_locale = (
getattr(chat_row, 'language_override', '') or
getattr(chat_row, 'language_code', '') or ''
)
if chat_locale:
effective_locale = chat_locale[:2].lower()
from ..services.http_session import get_http_session
message = _get_test_message(locale, "telegram")
message = _get_test_message(effective_locale, "telegram")
http = await get_http_session()
client = TelegramClient(http, bot.token)
return await client.send_message(chat_id, message)
@@ -406,7 +437,7 @@ def _bot_response(b: TelegramBot) -> dict:
"bot_username": b.bot_username,
"bot_id": b.bot_id,
"webhook_path_id": b.webhook_path_id,
"update_mode": b.update_mode or "polling",
"update_mode": b.update_mode or "none",
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
"created_at": b.created_at.isoformat(),
}
@@ -102,6 +102,37 @@ async def list_configs(
return [await _response(session, c) for c in result.all()]
@router.get("/defaults")
async def get_default_slot_templates(
provider_type: str,
slot_name: str | None = None,
locale: str | None = None,
user: User = Depends(get_current_user),
):
"""Return the shipped Jinja2 default templates for a provider type.
Used by the UI's "Reset to default" actions. Filtering is optional —
omit ``slot_name`` to get every slot, omit ``locale`` to get every locale.
Registered before ``/{config_id}`` so the literal path wins over the
path-parameter route in FastAPI's matcher.
Response shape: ``{slot_name: {locale: template_text}}``
"""
from notify_bridge_core.templates.defaults.loader import (
get_available_locales,
load_default_templates,
)
locales = [locale] if locale else get_available_locales()
result: dict[str, dict[str, str]] = {}
for loc in locales:
defaults = load_default_templates(loc, provider_type)
for name, text in defaults.items():
if slot_name and name != slot_name:
continue
result.setdefault(name, {})[loc] = text
return result
@router.get("/variables")
async def get_template_variables(
user: User = Depends(get_current_user),
@@ -170,13 +201,20 @@ async def get_template_variables(
"download_url": "Direct download URL (if shared)",
"photo_url": "Preview image URL (images only, if shared)",
"playback_url": "Video playback URL (videos only, if shared)",
# Per-asset album attribution (scheduled/memory templates in combined mode).
"album_name": "Source album name (combined-mode scheduled/memory only)",
"album_url": "Source album URL — public share link if available, else internal album URL",
"album_public_url": "Source album public share URL (empty if no public link)",
}
album_fields = {
"name": "Collection/album name",
"url": "Share URL",
"public_url": "Public share link URL",
"asset_count": "Total assets in collection",
"shared": "Whether collection is shared",
"photo_count": "Number of photos in the album",
"video_count": "Number of videos in the album",
"shared": "Whether collection is shared (boolean)",
"owner": "Album owner display name",
}
scheduled_vars = {
"date": "Current date string",
@@ -217,12 +255,26 @@ async def get_template_variables(
},
"scheduled_assets_message": {
"description": "Scheduled asset delivery (daily photo picks)",
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
"variables": {
**scheduled_vars,
"assets": "List of asset dicts (use {% for asset in assets %})",
"album_name": "Source album name",
"public_url": "Public share link URL for the source album (empty if none)",
"asset_count": "Total assets in the source album",
"photo_count": "Photos in the source album",
"video_count": "Videos in the source album",
"owner": "Source album owner",
},
"asset_fields": asset_fields,
},
"memory_mode_message": {
"description": "\"On This Day\" memories from previous years",
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
"variables": {
**scheduled_vars,
"assets": "List of asset dicts (use {% for asset in assets %})",
"album_name": "Source album name (when rendered per-album)",
"public_url": "Public share link URL for the source album (empty if none)",
},
"asset_fields": asset_fields,
},
# --- Generic Webhook slots ---
@@ -10,6 +10,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import TrackingConfig, User
from ..services.scheduler import reschedule_immich_dispatch_jobs
_LOGGER = logging.getLogger(__name__)
@@ -127,6 +128,8 @@ async def create_config(
session.add(config)
await session.commit()
await session.refresh(config)
if config.provider_type == "immich":
await reschedule_immich_dispatch_jobs()
return _response(config)
@@ -152,6 +155,8 @@ async def update_config(
session.add(config)
await session.commit()
await session.refresh(config)
if config.provider_type == "immich":
await reschedule_immich_dispatch_jobs()
return _response(config)
@@ -164,8 +169,11 @@ async def delete_config(
from .delete_protection import check_tracking_config, raise_if_used
config = await _get(session, config_id, user.id)
raise_if_used(await check_tracking_config(session, config.id), config.name)
provider_type = config.provider_type
await session.delete(config)
await session.commit()
if provider_type == "immich":
await reschedule_immich_dispatch_jobs()
def _response(c: TrackingConfig) -> dict:
@@ -76,16 +76,28 @@ def build_asset_dict(
public_url: str = "",
year: int | None = None,
) -> dict[str, Any]:
"""Build a rich asset dict for command templates from an ImmichAssetInfo or raw dict."""
"""Build a rich asset dict for command templates from an ImmichAssetInfo or raw dict.
Asset-dict contract (shared with notification templates see
``notify_bridge_core.templates.context``): templates may read either
``filename`` (the canonical field, used by notification defaults) or
``originalFileName`` (the historical command-default field); both are
populated so a custom template authored against either key keeps working.
Same story for ``created_at`` / ``createdAt``.
"""
if isinstance(asset, dict):
# Immich raw search responses nest geo under exifInfo — pull it out so
# templates can use flat asset.city / asset.country.
exif = asset.get("exifInfo") or {}
fname = asset.get("originalFileName") or asset.get("filename") or ""
created = asset.get("createdAt") or asset.get("created_at") or asset.get("fileCreatedAt") or ""
d = {
"id": asset.get("id", ""),
"originalFileName": asset.get("originalFileName", asset.get("filename", "")),
"filename": fname,
"originalFileName": fname,
"type": asset.get("type", "IMAGE"),
"createdAt": asset.get("createdAt", asset.get("created_at", asset.get("fileCreatedAt", ""))),
"created_at": created,
"createdAt": created,
"city": asset.get("city") or exif.get("city") or "",
"country": asset.get("country") or exif.get("country") or "",
"is_favorite": asset.get("is_favorite", asset.get("isFavorite", False)),
@@ -97,8 +109,10 @@ def build_asset_dict(
# ImmichAssetInfo dataclass
return {
"id": asset.id,
"filename": asset.filename,
"originalFileName": asset.filename,
"type": asset.type,
"created_at": asset.created_at,
"createdAt": asset.created_at,
"city": getattr(asset, "city", "") or "",
"country": getattr(asset, "country", "") or "",
@@ -144,7 +144,10 @@ async def migrate_schema(engine: AsyncEngine) -> None:
if bots:
logger.info("Backfilled webhook_path_id for %d existing bots", len(bots))
# Add update_mode to telegram_bot if missing
# Add update_mode to telegram_bot if missing.
# Existing bots pre-date this feature and were implicitly polling;
# preserve that behavior. New bots default to "none" via the
# SQLModel field default on fresh schemas.
if not await _has_column(conn, "telegram_bot", "update_mode"):
await conn.execute(
text("ALTER TABLE telegram_bot ADD COLUMN update_mode TEXT DEFAULT 'polling'")
@@ -49,7 +49,7 @@ class TelegramBot(SQLModel, table=True):
bot_username: str = Field(default="")
bot_id: int = Field(default=0)
webhook_path_id: str = Field(default_factory=lambda: uuid4().hex)
update_mode: str = Field(default="polling") # "polling" or "webhook"
update_mode: str = Field(default="none") # "none", "polling", or "webhook"
# NOTE: commands_config column remains in the DB for backward compat,
# but is no longer part of the SQLModel class. Data migrated to CommandConfig.
created_at: datetime = Field(default_factory=_utcnow)
@@ -173,7 +173,7 @@ class TelegramBotData(BaseModel):
token: str = ""
icon: str = ""
bot_username: str = ""
update_mode: str = "polling"
update_mode: str = "none"
class MatrixBotData(BaseModel):
@@ -120,22 +120,43 @@ async def dispatch_test_notification(
),
}
# Fetch assets and build event
# Build events (single or per-album) via the shared helper so test and
# cron dispatch stay in lockstep on the mode decision.
try:
event = await _build_event(
provider_type=provider.type,
provider_config=provider_config,
provider_name=provider.name or provider.type,
tracker_name=tracker.name or "",
tracker_filters=dict(tracker.filters) if tracker.filters else {},
collection_ids=collection_ids,
test_type=test_type,
tracking_config=tracking_config,
)
if provider.type == "immich" and test_type in ("periodic", "scheduled", "memory"):
events = await build_immich_dispatch_events(
provider_config=provider_config,
provider_name=provider.name or provider.type,
tracker_name=tracker.name or "",
collection_ids=collection_ids,
kind=test_type,
tracking_config=tracking_config,
)
else:
ev = await _build_event(
provider_type=provider.type,
provider_config=provider_config,
provider_name=provider.name or provider.type,
tracker_name=tracker.name or "",
tracker_filters=dict(tracker.filters) if tracker.filters else {},
collection_ids=collection_ids,
test_type=test_type,
tracking_config=tracking_config,
)
events = [ev] if ev is not None else []
except Exception as err: # noqa: BLE001
_LOGGER.exception("Test dispatch event build failed")
return {"success": False, "error": f"Provider connection failed: {err}"}
if event is None:
if not events:
if test_type in ("scheduled", "memory"):
return {
"success": False,
"error": (
"No matching assets found. Verify the tracker's albums contain assets "
"that pass the tracking config filters (favorites only, rating, asset type)."
) + (" for today" if test_type == "memory" else ""),
}
return {
"success": False,
"error": (
@@ -143,24 +164,92 @@ async def dispatch_test_notification(
"credentials are valid, and the tracker has collections configured."
),
}
# Periodic summary only needs album stats (extra.albums), not assets — skip the asset check.
if not event.added_assets and test_type in ("scheduled", "memory"):
return {
"success": False,
"error": (
"No matching assets found. Verify the tracker's albums contain assets "
"that pass the tracking config filters (favorites only, rating, asset type)."
) + (" for today" if test_type == "memory" else ""),
}
# Dispatch through the real NotificationDispatcher
# Dispatch each event to the same target (per-album fan-out sends N messages).
url_cache, asset_cache = await _get_telegram_caches()
dispatcher = NotificationDispatcher(url_cache=url_cache, asset_cache=asset_cache)
results = await dispatcher.dispatch(event, [target_cfg])
all_results: list[dict[str, Any]] = []
for event in events:
results = await dispatcher.dispatch(event, [target_cfg])
if results:
all_results.append(results[0])
if not results:
if not all_results:
return {"success": False, "error": "No dispatch results"}
return results[0]
all_ok = all(r.get("success") for r in all_results)
if all_ok:
return {"success": True, "dispatched": len(all_results)}
first_err = next(
(r.get("error") for r in all_results if not r.get("success")),
"Unknown error",
)
return {
"success": False,
"error": first_err,
"dispatched": sum(1 for r in all_results if r.get("success")),
"failed": sum(1 for r in all_results if not r.get("success")),
}
async def build_immich_dispatch_events(
*,
provider_config: dict,
provider_name: str,
tracker_name: str,
collection_ids: list[str],
kind: str,
tracking_config: TrackingConfig | None,
) -> list[ServiceEvent]:
"""Build the list of ServiceEvents to dispatch for an Immich scheduled kind.
Single source of truth for the mode decision: ``periodic`` is always one
summary event; ``scheduled``/``memory`` honour the ``{kind}_collection_mode``
on the tracking config and fan out one event per album in ``per_collection``
mode, or one combined event in ``combined`` mode.
Empty-payload filtering (no assets matched) is applied here so callers get
back only events that should actually dispatch. ``periodic`` is exempt
a zero-asset summary is still meaningful (shows album stats only).
"""
if kind == "periodic":
ev = await _build_immich_periodic_event(
provider_config=provider_config,
provider_name=provider_name,
tracker_name=tracker_name,
collection_ids=collection_ids,
)
return [ev] if ev is not None else []
mode = getattr(
tracking_config, f"{kind}_collection_mode", "combined"
) or "combined"
if mode == "per_collection" and len(collection_ids) > 1:
events: list[ServiceEvent] = []
for aid in collection_ids:
ev = await _build_immich_event(
provider_config=provider_config,
provider_name=provider_name,
tracker_name=tracker_name,
collection_ids=[aid],
test_type=kind,
tracking_config=tracking_config,
)
if ev is not None and ev.added_assets:
events.append(ev)
return events
ev = await _build_immich_event(
provider_config=provider_config,
provider_name=provider_name,
tracker_name=tracker_name,
collection_ids=collection_ids,
test_type=kind,
tracking_config=tracking_config,
)
if ev is None or not ev.added_assets:
return []
return [ev]
async def _build_event(
@@ -8,12 +8,18 @@ IMPORTANT: Keep sample assets and context in sync with:
When adding new template variables, update all four locations.
"""
# Sample asset matching what build_asset_detail() actually returns
# Sample asset matching what build_asset_detail() / build_asset_dict() actually
# return. Command-template defaults use ``asset.filename`` (the canonical key
# shared with notification templates); ``originalFileName`` and ``createdAt``
# are kept as aliases so user templates authored against the historical command
# keys still preview correctly.
_SAMPLE_ASSET = {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"filename": "IMG_001.jpg",
"originalFileName": "IMG_001.jpg",
"type": "IMAGE",
"created_at": "2026-03-19T10:30:00",
"createdAt": "2026-03-19T10:30:00",
"owner": "Alice",
"owner_id": "user-uuid-1",
"description": "Family picnic",
@@ -32,12 +38,18 @@ _SAMPLE_ASSET = {
"file_size": 3_500_000, # 3.5 MB — original asset bytes
"playback_size": None, # photos are sent as-is, no transcoded variant
"oversized": False,
# Per-asset album attribution — populated by collect_scheduled_assets so
# combined-mode templates can label each row with its source album.
"album_name": "Family Photos",
"album_url": "https://immich.example.com/share/abc123",
"album_public_url": "https://immich.example.com/share/abc123",
}
_SAMPLE_VIDEO_ASSET = {
**_SAMPLE_ASSET,
"id": "d4e5f6a7-b8c9-0123-defg-456789abcdef",
"filename": "VID_002.mp4",
"originalFileName": "VID_002.mp4",
"type": "VIDEO",
"is_favorite": False,
"rating": None,
@@ -54,7 +66,10 @@ _SAMPLE_COLLECTION = {
"url": "https://immich.example.com/share/abc123",
"public_url": "https://immich.example.com/share/abc123",
"asset_count": 42,
"photo_count": 37,
"video_count": 5,
"shared": True,
"owner": "Alice",
}
# Full context covering ALL possible template variables
@@ -103,7 +118,9 @@ _SAMPLE_CONTEXT = {
# Scheduled/periodic variables (for those templates)
"collections": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
"albums": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "id": "x1y2z3", "filename": "IMG_002.jpg", "city": "London", "country": "UK", "public_url": "https://immich.example.com/share/abc123/photos/x1y2z3"}],
# Second sample asset belongs to a different album so the preview exercises
# the combined-mode branch (>1 distinct album → per-row "— Album" suffix).
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "id": "x1y2z3", "filename": "IMG_002.jpg", "originalFileName": "IMG_002.jpg", "city": "London", "country": "UK", "public_url": "https://immich.example.com/share/def456/photos/x1y2z3", "album_name": "Vacation 2025", "album_url": "https://immich.example.com/share/def456", "album_public_url": "https://immich.example.com/share/def456"}],
"date": "2026-03-19",
"photo_count": 30,
"video_count": 5,
@@ -0,0 +1,339 @@
"""Cron-fired scheduled / periodic / memory dispatch for Immich trackers.
The Immich provider exposes three notification slots that fire on a wall-clock
schedule rather than in response to album changes:
* ``scheduled_assets_message`` random asset selection at fixed times of day
* ``periodic_summary_message`` album stats summary at fixed times of day
* ``memory_mode_message`` "On This Day" memories at fixed times of day
The fire times live on the tracker's default ``TrackingConfig`` as comma-
separated ``HH:MM`` strings (``scheduled_times`` / ``periodic_times`` /
``memory_times``) interpreted in the app-level IANA timezone
(``AppSetting.timezone``). The scheduler module wires the cron jobs; this
module owns the dispatch flow once a job fires.
Note on per-link tracking-config overrides: schedule *times* come from the
tracker's default config — a per-link override may disable the slot for that
link (via ``{kind}_enabled``) but cannot shift its fire time. Consistent with
the test-dispatch path in ``manual_dispatch``.
"""
from __future__ import annotations
import logging
from typing import Literal
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.models.events import EventType
from notify_bridge_core.notifications.dispatcher import (
NotificationDispatcher,
TargetConfig,
)
from ..database.engine import get_engine
from ..database.models import (
EventLog,
NotificationTracker,
ServiceProvider,
TemplateSlot,
TrackingConfig,
)
from .dispatch_helpers import (
event_allowed_by_config,
get_app_timezone,
load_link_data,
)
from .manual_dispatch import build_immich_dispatch_events
_LOGGER = logging.getLogger(__name__)
ScheduledKind = Literal["scheduled", "periodic", "memory"]
# Reasons a scheduled cron fire can end up producing no notification. We write
# these to EventLog.details.skip_reason so users can see *why* a 09:00 memory
# didn't arrive, rather than silently treating the fire as if it never happened.
_SKIP_REASON_TRACKER_DISABLED = "tracker_disabled"
_SKIP_REASON_NOT_IMMICH = "not_immich_provider"
_SKIP_REASON_KIND_DISABLED = "kind_disabled_on_default_config"
_SKIP_REASON_NO_LINKS = "no_enabled_links"
_SKIP_REASON_NO_EVENT = "provider_returned_no_event"
_SKIP_REASON_EMPTY_PAYLOAD = "zero_assets_matched"
_SKIP_REASON_NO_TARGETS = "no_targets_after_filtering"
async def _log_skip(
tracker_id: int,
kind: ScheduledKind,
reason: str,
*,
tracker_user_id: int | None = None,
tracker_name: str = "",
provider_id: int | None = None,
provider_name: str = "",
) -> None:
"""Persist an EventLog row for a skipped scheduled fire.
Separate from the success-path log (which records targets dispatched) so
operators and users can filter "why didn't this fire" from "what was sent".
``event_type`` mirrors the success path's value; the skip is disambiguated
by ``details.status == "skipped"``.
"""
engine = get_engine()
async with AsyncSession(engine) as session:
session.add(EventLog(
user_id=tracker_user_id,
tracker_id=tracker_id,
tracker_name=tracker_name,
provider_id=provider_id,
provider_name=provider_name,
event_type=EventType.SCHEDULED_MESSAGE.value,
collection_id="",
collection_name="",
assets_count=0,
details={
"kind": kind,
"trigger": "cron",
"status": "skipped",
"skip_reason": reason,
},
))
await session.commit()
# Maps the dispatch kind to the DB slot name that holds its template.
# The dispatcher keys templates by ``event.event_type.value`` (always
# ``scheduled_message`` here), so we read the right ``TemplateSlot`` row and
# inject it under that single event-type key — same pattern as the test path.
_SLOT_MAP: dict[ScheduledKind, str] = {
"scheduled": "scheduled_assets_message",
"periodic": "periodic_summary_message",
"memory": "memory_mode_message",
}
async def dispatch_scheduled_for_tracker(
tracker_id: int, kind: ScheduledKind
) -> None:
"""Build the slot's event for ``tracker_id`` and fan out to its links.
Skips silently when the tracker is disabled, the provider is not Immich,
the slot is disabled on the tracker's default tracking config, or no link
has a ``TemplateConfig`` with the corresponding slot row.
"""
engine = get_engine()
async with AsyncSession(engine) as session:
tracker = await session.get(NotificationTracker, tracker_id)
if not tracker or not tracker.enabled:
# No user context available (tracker missing/disabled); still log so
# operators can correlate cron fires that went nowhere.
await _log_skip(
tracker_id, kind, _SKIP_REASON_TRACKER_DISABLED,
tracker_user_id=(tracker.user_id if tracker else None),
tracker_name=(tracker.name if tracker else ""),
)
return
provider = await session.get(ServiceProvider, tracker.provider_id)
if not provider or provider.type != "immich":
await _log_skip(
tracker_id, kind, _SKIP_REASON_NOT_IMMICH,
tracker_user_id=tracker.user_id,
tracker_name=tracker.name or "",
provider_id=(provider.id if provider else None),
provider_name=(provider.name if provider else ""),
)
return
default_tc: TrackingConfig | None = None
if tracker.default_tracking_config_id:
default_tc = await session.get(
TrackingConfig, tracker.default_tracking_config_id
)
# If the default config disables this kind, nothing to do — schedule
# rebuild only adds jobs when the flag is set, but a stale job from
# a previous DB state could still fire one tick before invalidation.
if default_tc is None or not getattr(default_tc, f"{kind}_enabled", False):
_LOGGER.debug(
"Scheduled %s skipped for tracker %d: kind disabled on default config",
kind, tracker_id,
)
await _log_skip(
tracker_id, kind, _SKIP_REASON_KIND_DISABLED,
tracker_user_id=tracker.user_id,
tracker_name=tracker.name or "",
provider_id=provider.id,
provider_name=provider.name or provider.type,
)
return
# Snapshot every field we need outside the session — after the
# ``async with`` exits the instances are detached and lazy-load
# would fail. Cheaper than re-fetching, safer than touching
# attributes through a closed session.
provider_id = provider.id
provider_config = dict(provider.config)
provider_name = provider.name or provider.type
tracker_user_id = tracker.user_id
tracker_name = tracker.name or ""
collection_ids = list(tracker.collection_ids or [])
app_tz = await get_app_timezone(session)
link_data = await load_link_data(session, tracker_id)
if not link_data:
_LOGGER.info(
"Scheduled %s for tracker %d: no enabled links, skipping",
kind, tracker_id,
)
await _log_skip(
tracker_id, kind, _SKIP_REASON_NO_LINKS,
tracker_user_id=tracker_user_id,
tracker_name=tracker_name,
provider_id=provider_id,
provider_name=provider_name,
)
return
# Resolve mode + build events via the shared helper (same decision logic
# the test-dispatch path uses). "per_collection" fans out one event per
# album; "combined" pools assets into a single event. ``collection_mode``
# is threaded through to EventLog.details so operators can see *which*
# mode a fire used when auditing behaviour.
collection_mode = (
"combined" if kind == "periodic"
else getattr(default_tc, f"{kind}_collection_mode", "combined") or "combined"
)
events = await build_immich_dispatch_events(
provider_config=provider_config,
provider_name=provider_name,
tracker_name=tracker_name,
collection_ids=collection_ids,
kind=kind,
tracking_config=default_tc,
)
if not events:
# All albums yielded 0 matching assets (per_collection), or the single
# combined build produced nothing. Log the same skip reason used for
# the legacy single-event path so operators see a consistent signal.
reason = (
_SKIP_REASON_NO_EVENT if kind == "periodic" else _SKIP_REASON_EMPTY_PAYLOAD
)
_LOGGER.info(
"Scheduled %s for tracker %d: no events to dispatch (mode=%s)",
kind, tracker_id, collection_mode,
)
await _log_skip(
tracker_id, kind, reason,
tracker_user_id=tracker_user_id,
tracker_name=tracker_name,
provider_id=provider_id,
provider_name=provider_name,
)
return
slot_name = _SLOT_MAP[kind]
# Lazy import to break the watcher↔scheduler↔scheduled_dispatch cycle.
from .watcher import _get_telegram_caches
from .http_session import get_http_session
url_cache, asset_cache = await _get_telegram_caches()
http_session = await get_http_session()
dispatcher = NotificationDispatcher(
url_cache=url_cache, asset_cache=asset_cache, session=http_session,
)
any_sent = False
for event in events:
# Target config assembly depends on the event for quiet-hours /
# event_allowed_by_config, which inspects event timestamp. Per-event
# rebuilding also lets a per-link override disable one kind while
# keeping others live.
target_configs: list[TargetConfig] = []
async with AsyncSession(engine) as session:
for ld in link_data:
tc = ld["tracking_config"] or default_tc
tmpl = ld["template_config"]
if tc is not None:
if not getattr(tc, f"{kind}_enabled", True):
continue
if not event_allowed_by_config(event, tc, app_tz):
continue
if tmpl is None:
continue
slot_rows = (await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == tmpl.id,
TemplateSlot.slot_name == slot_name,
)
)).all()
if not slot_rows:
continue
locale_map = {s.locale: s.template for s in slot_rows}
template_slots = {EventType.SCHEDULED_MESSAGE.value: locale_map}
target_configs.append(TargetConfig(
type=ld["target_type"],
config=ld["target_config"],
template_slots=template_slots,
date_format=tmpl.date_format,
date_only_format=(
tmpl.date_only_format or "%d.%m.%Y"
),
provider_api_key=provider_config.get("api_key"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("external_domain", ""),
receivers=ld["receivers"],
))
if not target_configs:
_LOGGER.info(
"Scheduled %s for tracker %d (collection=%r): no targets after filtering",
kind, tracker_id, event.collection_name,
)
continue
_LOGGER.info(
"Dispatching scheduled %s for tracker %d (collection=%r) to %d link(s)",
kind, tracker_id, event.collection_name, len(target_configs),
)
results = await dispatcher.dispatch(event, target_configs)
any_sent = True
successes = sum(1 for r in results if isinstance(r, dict) and r.get("success"))
async with AsyncSession(engine) as session:
session.add(EventLog(
user_id=tracker_user_id,
tracker_id=tracker_id,
tracker_name=tracker_name,
provider_id=provider_id,
provider_name=provider_name,
event_type=event.event_type.value,
collection_id=event.collection_id,
collection_name=event.collection_name,
assets_count=event.added_count or 0,
details={
"kind": kind,
"slot": slot_name,
"trigger": "cron",
"timezone": app_tz,
"collection_mode": collection_mode,
"status": "sent",
"targets_dispatched": len(target_configs),
"targets_succeeded": successes,
},
))
await session.commit()
if not any_sent:
# All events produced zero targets after filtering (quiet hours, etc.).
await _log_skip(
tracker_id, kind, _SKIP_REASON_NO_TARGETS,
tracker_user_id=tracker_user_id,
tracker_name=tracker_name,
provider_id=provider_id,
provider_name=provider_name,
)
@@ -111,6 +111,7 @@ async def start_scheduler() -> None:
await _load_tracker_jobs()
await _load_action_jobs()
await _load_immich_dispatch_jobs()
# Start Telegram bot polling for bots with active command listeners
from .telegram_poller import start_command_listener_polling
@@ -760,6 +761,10 @@ async def reschedule_cron_jobs_for_timezone_change() -> None:
"Rescheduled %d cron job(s) for new app timezone %s", rescheduled, tz.key,
)
# Immich scheduled/periodic/memory jobs are also CronTrigger-based and
# carry the same frozen-tz problem — rebuild them under the new tz.
await reschedule_immich_dispatch_jobs()
async def _run_action(action_id: int) -> None:
"""Run an action (called by APScheduler)."""
@@ -770,6 +775,155 @@ async def _run_action(action_id: int) -> None:
_LOGGER.error("Error running action %d: %s", action_id, e)
# ---------------------------------------------------------------------------
# Immich scheduled / periodic / memory dispatch (cron-fired)
#
# These three slots fire on wall-clock schedules taken from the tracker's
# default ``TrackingConfig`` (``scheduled_times``, ``periodic_times``,
# ``memory_times`` — comma-separated ``HH:MM`` strings) interpreted in the
# app-level IANA timezone. The dispatch flow lives in
# ``services.scheduled_dispatch``; this section just owns scheduling.
# ---------------------------------------------------------------------------
_IMMICH_DISPATCH_KINDS = ("scheduled", "periodic", "memory")
_IMMICH_DISPATCH_PREFIX = "immich_dispatch_"
def _parse_hhmm_list(raw: str) -> list[tuple[int, int]]:
"""Parse ``"09:00,18:30"`` → ``[(9, 0), (18, 30)]``, skipping bad entries.
A typo in one slot must not prevent the others from scheduling we log
and move on rather than raising.
"""
out: list[tuple[int, int]] = []
for part in (raw or "").split(","):
part = part.strip()
if not part:
continue
try:
h_str, m_str = part.split(":", 1)
hour, minute = int(h_str), int(m_str)
except ValueError:
_LOGGER.warning("Skipping invalid time literal %r", part)
continue
if not (0 <= hour <= 23 and 0 <= minute <= 59):
_LOGGER.warning("Skipping out-of-range time %r", part)
continue
out.append((hour, minute))
return out
async def _run_immich_dispatch(tracker_id: int, kind: str) -> None:
"""APScheduler entry point — wraps the dispatch helper to swallow errors."""
from .scheduled_dispatch import dispatch_scheduled_for_tracker
try:
await dispatch_scheduled_for_tracker(tracker_id, kind) # type: ignore[arg-type]
except Exception as err: # noqa: BLE001
_LOGGER.error(
"Immich %s dispatch for tracker %d failed: %s", kind, tracker_id, err,
)
async def _load_immich_dispatch_jobs() -> None:
"""Schedule cron jobs for every (tracker, kind, time) where the kind is on.
Reads each enabled Immich tracker's *default* tracking config — per-link
overrides only gate dispatch (handled in ``scheduled_dispatch``), they do
not influence the fire schedule.
"""
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from apscheduler.triggers.cron import CronTrigger
from ..database.engine import get_engine
from ..database.models import (
NotificationTracker,
ServiceProvider as ServiceProviderModel,
TrackingConfig,
)
engine = get_engine()
scheduler = get_scheduler()
tz = await _load_app_timezone()
async with AsyncSession(engine) as session:
trackers = (await session.exec(
select(NotificationTracker).where(NotificationTracker.enabled == True) # noqa: E712
)).all()
if not trackers:
return
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()}
tc_ids = list({
t.default_tracking_config_id for t in trackers
if t.default_tracking_config_id
})
tc_map: dict[int, TrackingConfig] = {}
if tc_ids:
rows = await session.exec(
select(TrackingConfig).where(TrackingConfig.id.in_(tc_ids))
)
tc_map = {tc.id: tc for tc in rows.all()}
scheduled = 0
for tracker in trackers:
if provider_types.get(tracker.provider_id) != "immich":
continue
tc = tc_map.get(tracker.default_tracking_config_id) if tracker.default_tracking_config_id else None
if tc is None:
continue
for kind in _IMMICH_DISPATCH_KINDS:
if not getattr(tc, f"{kind}_enabled", False):
continue
times_raw = getattr(tc, f"{kind}_times", "") or ""
for hour, minute in _parse_hhmm_list(times_raw):
job_id = f"{_IMMICH_DISPATCH_PREFIX}{kind}_{tracker.id}_{hour:02d}{minute:02d}"
scheduler.add_job(
_run_immich_dispatch,
CronTrigger(hour=hour, minute=minute, timezone=tz),
id=job_id,
args=[tracker.id, kind],
replace_existing=True,
max_instances=1,
)
scheduled += 1
_LOGGER.info(
"Scheduled Immich %s for tracker %d at %02d:%02d [tz=%s]",
kind, tracker.id, hour, minute, tz.key,
)
if scheduled:
_LOGGER.info(
"Loaded %d Immich scheduled/periodic/memory job(s) [tz=%s]",
scheduled, tz.key,
)
async def reschedule_immich_dispatch_jobs() -> None:
"""Drop and rebuild all Immich scheduled/periodic/memory jobs.
Cheap to call on every relevant mutation a typical install has only a
handful of trackers. Called from the tracker, link, and tracking-config
CRUD endpoints, and from ``reschedule_cron_jobs_for_timezone_change``.
"""
scheduler = get_scheduler()
for job in list(scheduler.get_jobs()):
if job.id.startswith(_IMMICH_DISPATCH_PREFIX):
scheduler.remove_job(job.id)
await _load_immich_dispatch_jobs()
# ---------------------------------------------------------------------------
# Scheduled backup
# ---------------------------------------------------------------------------