feat: large polish pass — UX fixes, per-chat scope, restore/backup, action events

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.
This commit is contained in:
2026-04-22 01:13:11 +03:00
parent b5ffab7ece
commit a7a2b4efa4
57 changed files with 2452 additions and 335 deletions
@@ -34,6 +34,9 @@ async def start_scheduler() -> None:
# Schedule daily cleanup of old event log entries
_schedule_event_cleanup()
# Schedule periodic Telegram chat title refresh
_schedule_telegram_chat_sync()
# Start debounced command auto-sync scheduler
from .command_sync import start_sync_scheduler
start_sync_scheduler()
@@ -60,6 +63,139 @@ def _schedule_event_cleanup() -> None:
_LOGGER.info("Scheduled daily event log cleanup at 03:00 UTC")
# Chat-title refresh tuning.
# Sweep runs daily as a fallback — we additionally refresh opportunistically
# on every incoming webhook/long-poll update (``save_chat_from_webhook``), so
# the sweep only catches chats that haven't sent anything recently.
_CHAT_SYNC_INTERVAL_HOURS = 24
_CHAT_SYNC_INITIAL_DELAY_SECONDS = 60
_CHAT_SYNC_CONCURRENCY = 10
def _schedule_telegram_chat_sync() -> None:
"""Schedule periodic refresh of Telegram chat titles via getChat."""
from apscheduler.triggers.interval import IntervalTrigger
scheduler = get_scheduler()
job_id = "refresh_telegram_chat_titles"
if scheduler.get_job(job_id):
return
scheduler.add_job(
_refresh_telegram_chat_titles,
IntervalTrigger(hours=_CHAT_SYNC_INTERVAL_HOURS),
id=job_id,
replace_existing=True,
max_instances=1,
next_run_time=None,
)
# Fire once shortly after startup so stale names refresh without waiting a day.
from datetime import datetime, timedelta, timezone
scheduler.add_job(
_refresh_telegram_chat_titles,
"date",
run_date=datetime.now(timezone.utc) + timedelta(seconds=_CHAT_SYNC_INITIAL_DELAY_SECONDS),
id="refresh_telegram_chat_titles_once",
replace_existing=True,
max_instances=1,
)
_LOGGER.info(
"Scheduled Telegram chat title refresh every %sh (concurrency %s)",
_CHAT_SYNC_INTERVAL_HOURS, _CHAT_SYNC_CONCURRENCY,
)
async def _refresh_telegram_chat_titles() -> None:
"""Refresh TelegramChat.title/username via getChat for all known chats.
Runs requests in bounded parallel (``_CHAT_SYNC_CONCURRENCY``) so a fleet
of 50 chats finishes in ~5 round-trips instead of 50. Telegram's
``getChat`` rate limit is well above 10 concurrent per bot, and the cap is
global across bots so we never flood the shared HTTP session.
"""
import asyncio
from collections import defaultdict
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.notifications.telegram.client import TelegramClient
from ..database.engine import get_engine
from ..database.models import TelegramBot, TelegramChat
from .http_session import get_http_session
engine = get_engine()
async with AsyncSession(engine) as session:
bots = (await session.exec(select(TelegramBot))).all()
bot_tokens = {b.id: b.token for b in bots if b.token}
if not bot_tokens:
return
chats = (await session.exec(select(TelegramChat))).all()
by_bot: dict[int, list[TelegramChat]] = defaultdict(list)
for chat in chats:
if chat.bot_id in bot_tokens:
by_bot[chat.bot_id].append(chat)
if not by_bot:
return
http = await get_http_session()
clients_by_bot = {
bot_id: TelegramClient(http, token) for bot_id, token in bot_tokens.items()
}
sem = asyncio.Semaphore(_CHAT_SYNC_CONCURRENCY)
async def _fetch(bot_id: int, chat: TelegramChat) -> tuple[int, dict | None, str | None]:
"""Return (chat_row_id, info_dict_or_None, error_message_or_None)."""
async with sem:
try:
res = await clients_by_bot[bot_id].get_chat(chat.chat_id)
except Exception as err: # noqa: BLE001
return chat.id, None, str(err)
if not res.get("success"):
return chat.id, None, res.get("error") or "unknown"
return chat.id, (res.get("result") or {}), None
tasks = [
_fetch(bot_id, chat)
for bot_id, bot_chats in by_bot.items()
for chat in bot_chats
]
results = await asyncio.gather(*tasks)
refreshed = 0
errors = 0
async with AsyncSession(engine) as session:
for chat_id, info, err in results:
if err is not None or info is None:
errors += 1
if err:
_LOGGER.debug("getChat failed for chat row %s: %s", chat_id, err)
continue
merged = await session.get(TelegramChat, chat_id)
if not merged:
continue
title = info.get("title") or (
(info.get("first_name", "") + " " + info.get("last_name", "")).strip()
)
changed = False
if title and merged.title != title:
merged.title = title
changed = True
new_username = info.get("username")
if new_username is not None and merged.username != new_username:
merged.username = new_username
changed = True
if changed:
session.add(merged)
refreshed += 1
await session.commit()
_LOGGER.info(
"Telegram chat title refresh: %s updated, %s errors", refreshed, errors
)
async def _cleanup_old_events() -> None:
"""Delete EventLog entries older than 90 days."""
from datetime import datetime, timedelta, timezone