Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 187b889c45 | |||
| b61394f057 | |||
| be15463fd2 | |||
| 461fb495d7 | |||
| 309dec2b44 |
+15
-51
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -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:00–07:00 are supported.",
|
||||
"favoritesOnly": "Only include assets marked as favorites.",
|
||||
"maxAssets": "Maximum number of asset details to include in a single notification message.",
|
||||
"periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.",
|
||||
"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.",
|
||||
|
||||
@@ -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:00–07:00.",
|
||||
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
|
||||
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
|
||||
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
|
||||
"periodicStartDate": "Опорная дата в часовом поясе приложения. Первая сводка отправится в ближайшее заданное время ЧЧ:ММ, начиная с этой даты, затем каждые N дней.",
|
||||
"intervalDays": "Период между сводками в днях. 1 = ежедневно, 7 = еженедельно.",
|
||||
"times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00",
|
||||
"albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.",
|
||||
"scheduledAlbumMode": "Как альбомы группируются в запланированных отправках. По умолчанию: По альбому (одно уведомление на каждый отслеживаемый альбом).",
|
||||
"memoryAlbumMode": "Как альбомы группируются в воспоминаниях. По умолчанию: Объединённый (одно уведомление со всеми совпадениями из всех альбомов).",
|
||||
"minRating": "Включать только ассеты с рейтингом не ниже указанного (0 = без фильтра).",
|
||||
"eventMessages": "Шаблоны уведомлений о событиях в реальном времени. Используйте {переменные} для динамического контента.",
|
||||
"assetFormatting": "Форматирование отдельных ассетов в сообщениях уведомлений.",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user