common._format_assets was passing cache_key=<bare asset UUID>, but the
notification dispatcher writes keys as <host>:<uuid> (derived from the
URL by extract_asset_id_from_url). Result: the two paths populated
different keys for the same asset, so neither could hit the other's
cached file_id and the WebUI stats only ever reflected the notification
side.
Drop the explicit cache_key — TelegramClient derives <host>:<uuid> from
the URL, identical to the notification path, so one file_id cached by
any dispatch or /random / /latest reply is reused by every later send.
- Route cache_key values that look like asset UUIDs through asset_cache
in TelegramClient._get_cache_and_key. Single-asset sends previously
stored file_ids in url_cache while the media-group path stored them
in asset_cache, so repeat sends never hit.
- Extract build_asset_media_urls so the notification dispatcher
(asset_to_media) and the bot command handlers (common._format_assets)
share one rule for /video/playback vs thumbnail URLs.
- Add services/telegram_send.py as the single factory for constructing
a TelegramClient. It always wires the shared aiohttp session and both
file caches, so commands now reuse file_ids populated by notification
dispatches (and vice versa) instead of re-uploading the same bytes.
- send_reply / send_media_group in commands/handler.py now delegate to
the factory rather than constructing their own uncached clients.
- UI: command-template-configs now resolves slot variables against the
active provider first (varsRef[provider_type][slot]) before falling back
to shared entries, so provider-specific slots like /search, /status,
/repos, /issues, /boards show the Variables button and autocomplete.
- Backend: /search, /find, /person, /place now normalize raw Immich API
responses through build_asset_dict, extracting city/country from
exifInfo and mapping isFavorite -> is_favorite so templates render
location and favorite indicators.
- Telegram: extract build_telegram_asset_entry into a shared helper so
the notification dispatcher and command media groups agree on video
typing and /video/playback URLs; videos no longer render as still
thumbnails in /latest /random /favorites media mode.
- Commands: send_media_group now reuses the same Telegram file_id caches
as the notification dispatcher, avoiding re-upload churn for repeated
commands.
GET /settings returns the Telegram webhook secret masked as "***<last4>".
The frontend binds that masked value into its state, and any Save ships it
back — the PUT handler then persisted the mask as the new secret, silently
invalidating HMAC for every webhook-mode bot. The next GET re-masks the
mask to itself, so the UI showed no corruption.
Treat incoming values that begin with "***" as "unchanged" for the
webhook-secret field. Empty strings still pass through (explicit clear).
Svelte bind:value on <input type="number"> coerces to a JS number, so the
frontend sends {telegram_cache_ttl_hours: 0} after v0.2.4. Pydantic v2
won't auto-coerce int -> str, which produced a 422 on every save that
touched a numeric setting.
- Widen numeric fields to int | str | None in SettingsUpdate.
- Normalize to str before persisting (DB column is text).
Cache engine:
- TelegramFileCache: configurable max_entries (LRU cap applies in both TTL
and thumbhash modes), ttl_seconds<=0 disables TTL, stats() method.
- Dispatcher builds an asset.id -> thumbhash resolver from event.added_assets
(Immich populates thumbhash in extra) and passes it to TelegramClient, so
asset-cache entries invalidate on visual change rather than age.
- Watcher wires app settings into cache init: URL cache = TTL + LRU cap,
asset cache = thumbhash + LRU cap. Adds soft-reset (in-memory only) used
when cache params change.
Settings:
- New key telegram_asset_cache_max_entries (default 5000).
- telegram_cache_ttl_hours default bumped 48 -> 720 (30d); now URL-only.
- PUT /settings resets in-memory caches when cache keys change (files kept).
- New endpoints: GET/POST /settings/telegram-cache/stats and /clear.
Settings page:
- Cache stats card (count + size + oldest/newest per bucket) with a hint
explaining that the size is cumulative uploaded-to-Telegram bytes.
- Clear-cache button behind a confirm modal.
- New TimezoneSelector + LocaleSelector components replace raw inputs.
- max-entries input, TTL range updated (0..8760, 0 = disabled).
Mobile nav:
- "More" panel now mirrors the full sidebar tree (groups + subnodes) so
every destination is reachable on mobile; previously flat hand-picked list.
- Nav height uses env(safe-area-inset-bottom); panel bottom + z-index fixed
so content can't visually overlay the bottom bar.
A11y / DOM warnings:
- Password-change form has a hidden username field for password-manager
association; autocomplete hints on all three password inputs.
- Telegram webhook secret wrapped in a no-op form + autocomplete=off.
Bug fix:
- update_settings used any(await ... for ...) which raised TypeError at
runtime (async generator not an iterator); replaced with explicit loop.
trackers_active / trackers_total are per-provider aggregates — once the
rest of /status is scoped to the chat's album set (total_albums and
last_event both filtered by the derived scope), leaving tracker counts
in would leak info about trackers this chat has no visibility into.
- _cmd_status no longer emits trackers_active / trackers_total.
- Immich default status templates (en, ru) just show Albums + Last event.
- Variable catalog updated so the template editor stops suggesting the
removed vars for the Immich /status slot.
The "per-chat album scope" feature stored on CommandTrackerListener was
really per-bot: listener_id = bot.id, and every chat that bot served
shared the same scope. Commands like /albums, /random, /status,
/events leaked the full provider catalog into chats that were never
wired up to receive notifications from those trackers.
New model: the album scope for /commands in a given chat is derived
from the notification-routing graph. For a (provider, bot, chat_id)
triple we walk TargetReceiver (chat_id match, enabled) →
NotificationTarget (telegram or broadcast parent) →
NotificationTrackerTarget → NotificationTracker (provider match) and
union their collection_ids. That's the natural "what does this chat
get notifications about" set, and it becomes the command scope.
- New helper: command_utils.resolve_chat_album_scope(provider_id,
bot_id, chat_id) -> set[str]. Empty set is the default for chats
with no routing — commands return nothing rather than leaking the
provider's catalog.
- Dispatcher computes the scope per (tracker, bot, chat) and threads
it through handler.handle(..., allowed_album_ids=...). Explicit
CommandTrackerListener.allowed_album_ids override, when set, still
wins verbatim (kept as an escape hatch for users who want a divergent
scope for a whole bot).
- /status, /albums, /events, and all /_cmd_immich-routed commands
(/random, /search, /find, /latest, /memory, /summary, /favorites,
/place, /person) now intersect with the resolved scope.
- UI scope modal relabeled: it's an explicit *override for this bot*,
not a per-chat setting. Default is "derive from notification
routing", which matches what users already configured elsewhere.
Also:
- /search, /find, /person, /place — _enrich_assets return value was
discarded, dropping public_url enrichment. Assign the return value.
- search_smart / search_metadata — consolidated into _search_items
helper that logs non-200 responses and transport errors instead of
silently returning []. Makes "always no results" bugs actually
diagnosable. Also accepts the alternate {"assets": [...]} flat-list
shape from older Immich versions.
- Immich search error bodies go through _redact_body so credentials
echoed by authenticating proxies don't land in server logs.
- /albums ignored CommandTrackerListener.allowed_album_ids and listed
every album tracked by the provider — scoped chats saw neighbours'
albums. Thread the listener through _cmd_albums and apply the same
intersect filter the media commands already use in _cmd_immich.
- Command text replies are listings (albums, events, people, ...) that
embed multiple links; Telegram's default behavior of rendering a
preview for the first URL is never useful here and ignored the
"Disable link previews" toggle operators set on their target. Always
pass disable_web_page_preview=True from send_reply.
Homelab/LAN Immich instances trip the SSRF guard (Host 192.168.x resolves
to blocked address). The fix is to set NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
in the runtime env — call that out directly in the error message so
operators don't have to dig through source to find it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add quiet_hours_enabled/start/end to TrackingConfig (HH:MM strings
interpreted in the app-level timezone AppSetting). The dispatch path
loads the app timezone once per run and passes it through
event_allowed_by_config -> in_quiet_hours, so overnight windows like
22:00-07:00 work correctly in any IANA tz.
Frontend exposes a Timezone field under Settings and a Quiet Hours
section on the Immich tracking-config form with time-picker inputs.
Security
- Sign pending_restore.json (SHA256 stored in AppSetting, verified on
startup apply) + refuse path outside data_dir, tighten to 0600.
- Require same-origin Origin/Referer on POST /api/backup/apply-restart —
Bearer-in-localStorage is CSRF-reachable from any XSS'd admin tab.
- Bump token_version on role/username change and admin password reset so
demoted admins lose admin in already-issued JWTs. Guard last-admin
TOCTOU via COUNT + post-commit re-check that rolls back a race.
- SSRF guard (validate_outbound_url) in ImmichClient.__init__ and the
external_domain setter — admin-mutable URLs were bypassing the check
that webhook/slack/discord paths already used. Dev restart script now
sets NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 so homelab Immich still works.
- Redact + cap Immich error bodies to ~120 chars before they flow into
ActionExecution.error / EventLog.details (both UI-visible).
- Deny-list sensitive keys (api_key / token / secret / password /
authorization / cookie / ...) in template-context merges so a rogue
template can't exfiltrate provider creds via {{ api_key }}.
- Cap user-controlled Immich search params (query ≤256, person_ids ≤50,
size ≤100) so a Telegram listener can't DoS upstream.
- Stream upload reads with running byte counter + content-length precheck
instead of buffering the full body and then rejecting.
- Log Telegram parse_mode fallbacks instead of swallowing silently;
template escape bugs now surface in server logs.
- Rollback partial imports on pending-restore failure (error recorded on
a fresh session).
Performance
- Fix N+1 in _refresh_telegram_chat_titles: single IN query instead of
session.get per chat.
- Parallelize album + shared-link fetches in test_dispatch (asyncio.gather)
and per-receiver Telegram test sends in notifier (semaphore 5).
- Early-exit collect_scheduled_assets(limit=0) so the periodic-summary
test path skips full per-album filter/sample (was O(album_assets)).
- Emit explicit CREATE INDEX IF NOT EXISTS for event_log user_id /
action_id / provider_id so the first boot after upgrade isn't left
unindexed for the dashboard query.
- Add AbortController timeout (120s) to fetchAuth so uploads/downloads
don't hang indefinitely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- dispatch_test_notification now resolves tracking_config / template_config
from the tracker's default_* fields when the per-link override is unset,
matching what load_link_data does for the real watcher. Previously
periodic/scheduled/memory tests silently failed with "no template
defined" whenever the user configured the template config at the
tracker level instead of on each link (the UI's normal default).
- Distinguish the two missing-template cases in the returned error
("no template config linked" vs. "slot missing in linked config").
- Frontend testTrackerTarget now treats {success:false,error:"..."} in a
2xx body as a failure — previously any 2xx flashed a success snack so
users never saw the real reason their test didn't deliver.
Backend
- Per-chat album scope for Immich commands (search/latest/memory/...): new
allowed_album_ids on CommandTrackerListener, threaded listener/page kwargs
through ProviderCommandHandler.handle; PATCH listener-scope endpoint.
- /search and /find accept a trailing page number; Immich client search_smart
/ search_metadata take a page param.
- Immich person-asset lookup switched from removed GET /api/people/{id}/assets
to POST /api/search/metadata with personIds (fixes /person command and
auto_organize rules silently returning zero candidates on Immich 1.106+).
- Auto_organize rule now sets the target album's thumbnail to the first added
image when missing (falls back to any asset type); failures do not fail the
rule. add_assets_to_album surfaces the Immich error body on non-2xx.
- EventLog.user_id / action_id / action_name columns with defensive migration
+ backfill. Status query filters by user_id directly; Immich/webhook paths
emit user_id explicitly. action_runner writes an action_success/partial/
failed event on each non-dry-run.
- Dashboard DELETE /api/status/events (scoped to user_id) + rendering live
tracker/provider/action names via FK join with snapshot fallback.
- PATCH /api/users/{id} for username/role change with last-admin guard.
- Deletion protection returns structured {message, entity, blocked_by}
(ApiError carries .blockedBy; frontend opens BlockedByModal).
- Backup prepare-restore → AppSetting markers + atomic write of
pending_restore.json; lifespan hook applies on next startup and archives
under data/applied_restores/. apply-restart sends SIGTERM so the lifespan
shutdown runs; NOTIFY_BRIDGE_SUPERVISED env override gates the button.
Manual POST /api/backup/files (same format as scheduled).
- New periodic-summary test path reuses shared collect_scheduled_assets
(limit=0) so test and future production code go through one primitive.
- Per-receiver locale for Telegram test messages (resolves
TelegramChat.language_override per chat instead of applying the first
receiver's locale to everyone).
- Bounded concurrency (semaphores) in NotificationDispatcher._preload_asset_data
and _refresh_telegram_chat_titles; chat title sweep extended to 24h since
save_chat_from_webhook covers active chats opportunistically.
- Telegram poller detects the \"webhook is active\" 409 and auto-calls
deleteWebhook for bots whose DB update_mode is polling (throttled per bot).
- TelegramClient.get_chat added (CLAUDE.md rule 6); set_album_thumbnail added.
- Seeds: rename \"Default Commands\" → \"Default Immich Commands\";
track_assets_removed default False.
Frontend
- Global provider selector visible when there is only one provider.
- Clear-events button + i18n + ConfirmModal on the dashboard; new icons/
labels/filters/colors for action_success / action_partial / action_failed.
- Auto-select first available tracking/template/command/config + bot on
create forms (trackers, command-trackers, targets, template/command
configs).
- Telegram target disable_url_preview defaults to true.
- BlockedByModal wired into 8 deletion flows; fetchAuth helper for
multipart/binary calls (reuses api()'s refresh + ApiError mapping).
- Immich tracker 'Checking links' parallelised (concurrency cap 6).
- Backup page: pending-restore banner + Apply-now / Apply-later modal,
restarting overlay polling /api/health, manual 'Create backup' button.
- Command-trackers listener row gets an 'Edit album scope' modal with
inherit/explicit multiselect.
- Users page: Edit user modal (username + role).
- parseDate helper for consistent UTC date rendering.
Migrations / schema
- event_log: + user_id, action_id, action_name (+ backfill user_id from
notification_tracker).
- command_tracker_listener: + allowed_album_ids.
Creating or updating a command tracker failed with 404
"Command config not found" when the selected config was a system
default (seeded with user_id=0). The LIST endpoint already accepts
both owned and system-shared rows via
or_(CommandConfig.user_id == user.id, CommandConfig.user_id == 0)
so the frontend legitimately offered a user_id=0 option — the POST
and PATCH handlers then rejected it.
Align the create/update checks with the list behavior:
config.user_id not in (user.id, 0)
Deep-linking to non-root URLs like /settings or /notification-trackers
returned {"detail":"Not Found"} because StaticFiles(html=True) only
serves index.html for directory roots, not for arbitrary SPA routes.
Subclass StaticFiles with an SPA fallback: any 404 on a non-/api path
serves the root index.html, letting the SvelteKit router hydrate the
correct view on the client. Real /api/* 404s still bubble up as JSON
from FastAPI.
Telegram webhook handler crashed with sqlalchemy.exc.MissingGreenlet
when processing any incoming message after committing the chat row:
TelegramChat.bot_id == bot.id
^^^^^^
MissingGreenlet: greenlet_spawn has not been called
AsyncSession expires all instances on commit. Accessing bot.id/bot.token
after that triggers implicit lazy-load I/O from a sync attribute getter,
which can't enter the greenlet dispatcher → crash.
Fix: snapshot bot.id + bot.token to locals before commit, refresh the
ORM instance after a successful commit so handle_command() can still
use it, and route the remaining call sites through the snapshot
variables.
Startup was crashing on fresh databases because:
- init_db() calls SQLModel.metadata.create_all(), which builds tables
from the model classes. CommandConfig didn't declare `locale`, so
the created command_config table lacked the column.
- The seeder then issued INSERTs that included locale='en', causing
`OperationalError: table command_config has no column named locale`.
The legacy migration #6 in migrate_schema creates command_config WITH
locale via raw SQL, so upgraded databases worked. Only fresh installs
broke.
Fix:
- Add `locale: str = Field(default='en')` to CommandConfig model so
create_all() produces a consistent schema.
- Add a defensive ALTER TABLE ... ADD COLUMN locale in migrate_schema's
else-branch, so any existing command_config table missing the column
(from a broken v0.1.0 install) is backfilled on next startup.
- Remove top paginator from dashboard events, keep only bottom
- Fix test message locale: pass UI locale to email/matrix bot tests
- Convert webhook auth mode from text input to icon grid selector
- Generate secure UUID tokens for webhook URLs instead of sequential IDs
- Move Recent Payloads into per-provider expandable container (lazy-loaded)
- Make template config languages dynamic via app settings instead of hardcoded
- Change default dev port to 5175
Add person exclude criteria to Immich auto-organize — assets containing
excluded persons are filtered out after candidate gathering. Also adds
full backup/restore system with export, import, scheduled backups, and
retention management.
Add a new "webhook" provider type that accepts arbitrary HTTP POST payloads,
extracts template variables via user-defined JSONPath mappings, and dispatches
notifications through the existing pipeline. Supports three auth modes
(HMAC-SHA256, Bearer token, none), bounded JSONPath cache, and 1MB payload limit.
Full stack: core provider + event parser, API endpoint, DB migration,
capabilities, seeds, default templates (EN/RU), frontend descriptor, i18n.
- Tracker now has default_tracking_config_id and default_template_config_id
that apply to all linked targets unless overridden per-target
- Dispatch falls back to tracker defaults when per-link configs are null
- Email bot creation validates SMTP connection before saving
- Email notifications sent as HTML (links render properly)
- Linked target items are expandable: collapsed shows config CrossLinks,
expanded shows config selectors; action buttons always visible
- Fix email bot test button icon (mdiEmailSend → mdiSend)
- Fix target type icons in LinkedTargetsSection for all types
- Provider filter moved above search in sidebar
- Route scheduled/memory test sends through the same NotificationDispatcher
the watcher uses — identical template rendering, media handling, caching
- Add preview_url field to MediaAsset (transcoded mid-size), separate from
thumbnail_url (small) and full_url (original). Dispatcher prefers preview_url
- Fix sendMediaGroup cache: extract file_ids from Telegram response and store
via async_set_many so repeat sends use cached file_ids
- Parallelize asset downloads in _send_media_group with asyncio.gather
- Filter unprocessed assets (archived/trashed/offline/no-thumbhash) at album
parse time in ImmichAlbumData.from_api_response
- Extract shared asset_to_media + collect_scheduled_assets into asset_utils.py
(single source for test dispatch and future real scheduler)
- Respect tracking config filters: limit, asset_type, favorite_only, min_rating
- Random asset sampling for scheduled sends
- Memory mode: "On This Day" date filter (same month+day, previous year)
- Skip dispatch when no matching assets found
- Remove ~250 lines of duplicated send logic from notifier.py
- Fix restart-backend.sh: proper env var export, Python path resolution, error log
- Command templates now match notification template style: type icons,
linked filenames via album shared links, location, favorite status
- Media mode sends text message first, then media as reply (was media-only)
- Search/find/person/place resolve asset public URLs from tracked albums'
shared links (share/{key}/photos/{id})
- Albums/summary commands include album public_url in context
- Enriched command template preview sample context with public_url, city,
country, is_favorite
- Extract sanitizePreview to shared lib/sanitize.ts
- Command template preview now renders HTML links (was raw text)
- Global provider filter moved above search in sidebar
- CLAUDE.md: template consistency + context variable sync rules
When both internal URL and external domain are configured, rewrite
asset download URLs from external to internal before fetching.
This avoids routing through public internet when the bot and
provider are on the same LAN.
Add broadcast target type that fans out notifications to multiple
child targets. Dispatch expands broadcast into children in
load_link_data() — dispatcher stays unaware. Children can be
toggled on/off via disabled_child_ids in config.
Also: dashboard provider card smaller font for names, scroll-to-form
on target edit, broadcast nav tab with counter, flag_modified fix
for JSON column updates, CLAUDE.md nav tree docs.
- Introduce Receiver base class + typed subclasses (TelegramReceiver,
WebhookReceiver, EmailReceiver, etc.) in core/notifications/receiver.py
- Dispatcher uses typed Receiver objects instead of raw dicts, with
per-receiver locale-aware template rendering
- load_link_data resolves locale from TelegramChat.language_override at
load time: TargetReceiver.locale || chat.language_override || chat.language_code
- Add language_override field to TelegramChat (separate from auto-detected
language_code), with per-chat commands toggle and command dispatch using
override language
- Add locale field to TargetReceiver for explicit per-receiver overrides
- Add commands_enabled field to TelegramChat (default off) with
migration, gating command dispatch in both poller and webhook
- Show toggle switch per chat in bot tab for enabling/disabling commands
- Fix listener response to include bot name instead of just type
- Replace listener "Enabled" label + "Edit" link with toggle switch
and crosslink to command-trackers page
- Add locale support to notification templates (matching command template
pattern): TemplateSlot now has locale field with (config_id, slot_name,
locale) uniqueness, nested API format {slot: {locale: template}}
- Migration merges separate EN/RU system configs into unified per-provider
configs; seeds create one config per provider with multi-locale slots
- Locale-aware dispatch with EN fallback in NotificationDispatcher
- Frontend locale tabs (EN/RU) on template config editor
- Fix tracking config cards not showing default provider icons
- Global provider filter, search palette, and various UX polish
Video size warnings:
- Add file_size field to ImmichAssetInfo from exifInfo.fileSizeInByte
- Expose per-target max_video_size (50 MB for Telegram, none for others)
- Compute has_oversized_videos and per-asset oversized flag in template context
- Default templates show warning only when videos actually exceed the limit
- Templates no longer hardcode Telegram-specific logic
Template autocomplete:
- New jinja-autocomplete.ts engine with contextual completions
- Top-level variables ({{ }}), asset/album fields (dot access in loops),
Jinja2 filters (|), block tags ({% %}), and loop.* special vars
- JinjaEditor accepts optional variables prop via CodeMirror Compartment
- Wired into template-configs and command-template-configs pages
Also: fix template emoji (📷 → 📎) and sync sample_context with new vars.