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
+96 -1
View File
@@ -11,6 +11,37 @@ export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
return fallback;
}
/** Structured 409 blocked-by payload attached to ApiError.blockedBy. */
export interface BlockedByDetail {
message: string;
entity: string;
blocked_by: string[];
}
export class ApiError extends Error {
status: number;
blockedBy?: BlockedByDetail;
constructor(message: string, status: number, blockedBy?: BlockedByDetail) {
super(message);
this.name = 'ApiError';
this.status = status;
this.blockedBy = blockedBy;
}
}
/** Parse a server-issued datetime string as UTC (appends Z if no timezone info present). */
export function parseDate(dateStr: string): Date {
if (!dateStr) return new Date(NaN);
if (!/Z$|[+-]\d{2}:?\d{2}$/.test(dateStr)) return new Date(dateStr + 'Z');
return new Date(dateStr);
}
/** If the thrown error was a structured 409 from delete_protection, return its payload. */
export function getBlockedBy(err: unknown): BlockedByDetail | null {
if (err instanceof ApiError && err.blockedBy) return err.blockedBy;
return null;
}
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('access_token');
@@ -106,7 +137,17 @@ export async function api<T = any>(
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
// Structured blocked-by detail (from delete_protection.raise_if_used)
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
const bb: BlockedByDetail = {
message: err.detail.message || `HTTP ${res.status}`,
entity: err.detail.entity || '',
blocked_by: err.detail.blocked_by,
};
throw new ApiError(bb.message, res.status, bb);
}
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
throw new ApiError(msg, res.status);
}
return res.json();
@@ -114,3 +155,57 @@ export async function api<T = any>(
clearTimeout(timeout);
}
}
/**
* Auth-aware ``fetch`` wrapper for calls that can't go through ``api()`` —
* typically multipart/form-data uploads or binary downloads where we need the
* raw ``Response`` object rather than parsed JSON.
*
* - Injects the Bearer token automatically.
* - Does NOT set ``Content-Type`` (the caller's body — e.g. ``FormData`` —
* decides the encoding; browsers add the boundary).
* - Attempts a one-shot token refresh on 401, matching ``api()``.
* - Translates non-OK responses to ``ApiError`` so callers can use the same
* ``getBlockedBy`` / ``err.message`` handling pattern.
*/
export async function fetchAuth(
path: string,
options: RequestInit = {},
): Promise<Response> {
const token = getToken();
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) };
if (token) headers['Authorization'] = `Bearer ${token}`;
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
let res = await fetch(url, { ...options, headers });
if (res.status === 401 && token) {
const refreshed = await refreshAccessToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${getToken()}`;
res = await fetch(url, { ...options, headers });
}
}
if (res.status === 401) {
clearTokens();
if (typeof window !== 'undefined') window.location.href = '/login';
throw new ApiError('Unauthorized', 401);
}
if (!res.ok) {
const err = await res.clone().json().catch(() => ({ detail: res.statusText }));
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
const bb: BlockedByDetail = {
message: err.detail.message || `HTTP ${res.status}`,
entity: err.detail.entity || '',
blocked_by: err.detail.blocked_by,
};
throw new ApiError(bb.message, res.status, bb);
}
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
throw new ApiError(msg, res.status);
}
return res;
}
@@ -0,0 +1,74 @@
<script lang="ts">
import Modal from './Modal.svelte';
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
import type { BlockedByDetail } from '$lib/api';
let { open = false, detail = null, onclose } = $props<{
open: boolean;
detail: BlockedByDetail | null;
onclose: () => void;
}>();
const blockedCount = $derived(detail?.blocked_by?.length ?? 0);
</script>
<Modal {open} title={t('common.cannotDelete')} onclose={onclose}>
{#if detail}
<div class="flex items-start gap-3 mb-4">
<div class="flex items-center justify-center w-9 h-9 rounded-full flex-shrink-0"
style="background: var(--color-error-bg); color: var(--color-error-fg);">
<MdiIcon name="mdiLinkVariant" size={20} />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium mb-1 break-words">{detail.message}</p>
{#if detail.entity}
<p class="text-xs break-all" style="color: var(--color-muted-foreground);">{detail.entity}</p>
{/if}
</div>
</div>
<div class="flex items-center justify-between mb-2">
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('common.blockedByIntro')}</p>
{#if blockedCount > 0}
<span class="text-[0.65rem] font-mono px-1.5 py-0.5 rounded"
style="background: var(--color-muted); color: var(--color-muted-foreground);">
{blockedCount}
</span>
{/if}
</div>
{#if blockedCount > 0}
<ul class="space-y-1.5 max-h-64 overflow-y-auto pr-1 mb-5">
{#each detail.blocked_by as consumer}
<li class="flex items-start gap-2 text-sm px-3 py-2 rounded-md"
style="background: var(--color-muted); border: 1px solid var(--color-border);">
<span class="flex-shrink-0 mt-0.5" style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiChevronRight" size={14} />
</span>
<span class="font-mono text-xs break-all min-w-0 flex-1">{consumer}</span>
</li>
{/each}
</ul>
{/if}
{/if}
<div class="flex justify-end">
<button onclick={onclose} class="blocked-by-close-btn">
{t('common.close')}
</button>
</div>
</Modal>
<style>
.blocked-by-close-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-foreground);
cursor: pointer;
transition: all 0.2s ease;
}
.blocked-by-close-btn:hover {
background: var(--color-muted);
}
</style>
@@ -1,5 +1,6 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { parseDate } from '$lib/api';
import MdiIcon from './MdiIcon.svelte';
interface DayData {
@@ -47,7 +48,7 @@
const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0)));
function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
const d = parseDate(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
+3
View File
@@ -89,6 +89,9 @@ export const eventTypeFilterItems = (): GridItem[] => [
{ value: 'collection_renamed', icon: 'mdiRename', label: t('dashboard.filterRenamed'), desc: t('gridDesc.renamed') },
{ value: 'collection_deleted', icon: 'mdiDeleteAlert', label: t('dashboard.filterDeleted'), desc: t('gridDesc.deleted') },
{ value: 'sharing_changed', icon: 'mdiShareVariant', label: t('dashboard.filterSharingChanged'), desc: t('gridDesc.sharingChanged') },
{ value: 'action_success', icon: 'mdiPlayCircle', label: t('dashboard.filterActionSuccess'), desc: t('gridDesc.actionSuccess') },
{ value: 'action_partial', icon: 'mdiAlertCircle', label: t('dashboard.filterActionPartial'), desc: t('gridDesc.actionPartial') },
{ value: 'action_failed', icon: 'mdiCloseCircle', label: t('dashboard.filterActionFailed'), desc: t('gridDesc.actionFailed') },
];
// --- Sort filter (dashboard) ---
+39 -2
View File
@@ -64,6 +64,8 @@
"activeTrackers": "Active Trackers",
"targets": "Targets",
"recentEvents": "Events",
"clearEvents": "Clear",
"confirmClearEvents": "Delete all event log entries? This cannot be undone.",
"chart": "Event chart",
"noEvents": "No events yet. Create a tracker to start monitoring.",
"loading": "Loading...",
@@ -76,6 +78,9 @@
"collectionRenamed": "collection renamed",
"collectionDeleted": "collection deleted",
"sharingChanged": "sharing changed",
"actionSuccess": "action run",
"actionPartial": "action partial",
"actionFailed": "action failed",
"searchEvents": "Search events...",
"allEvents": "All Events",
"filterAssetsAdded": "Assets Added",
@@ -83,6 +88,9 @@
"filterRenamed": "Renamed",
"filterDeleted": "Deleted",
"filterSharingChanged": "Sharing Changed",
"filterActionSuccess": "Action Success",
"filterActionPartial": "Action Partial",
"filterActionFailed": "Action Failed",
"allProviders": "All Providers",
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
@@ -365,6 +373,7 @@
"roleAdmin": "Admin",
"create": "Create User",
"delete": "Delete",
"edit": "Edit user",
"confirmDelete": "Delete this user?",
"joined": "joined",
"noUsers": "No users found"
@@ -785,13 +794,21 @@
"disabled": "Disabled",
"noListeners": "No listeners attached.",
"selectBot": "Select bot...",
"listenerType": "telegram_bot"
"listenerType": "telegram_bot",
"editScope": "Edit album scope",
"scopeAll": "all albums",
"albumsShort": "albums",
"scopeTitle": "Album Scope for This Chat",
"scopeDescription": "Restrict which tracked albums this chat can query via commands. Leave on \"inherit\" to allow all albums from the tracker.",
"scopeInherit": "Inherit: allow all tracked albums",
"noCollections": "No albums available."
},
"snackbar": {
"showDetails": "Show details",
"hideDetails": "Hide details"
},
"snack": {
"eventsCleared": "{count} event(s) cleared",
"providerSaved": "Provider saved",
"providerDeleted": "Provider deleted",
"trackerCreated": "Tracker created",
@@ -810,6 +827,7 @@
"botDeleted": "Bot deleted",
"userCreated": "User created",
"userDeleted": "User deleted",
"userUpdated": "User updated",
"passwordChanged": "Password changed",
"copied": "Copied to clipboard",
"genericError": "Something went wrong",
@@ -827,6 +845,7 @@
"commandTrackerDisabled": "Command tracker disabled",
"listenerAdded": "Listener added",
"listenerRemoved": "Listener removed",
"listenerScopeSaved": "Scope updated",
"cmdTemplateSaved": "Command template saved",
"cmdTemplateDeleted": "Command template deleted",
"emailBotCreated": "Email bot created",
@@ -848,6 +867,8 @@
"description": "Description",
"close": "Close",
"confirm": "Confirm",
"cannotDelete": "Cannot delete",
"blockedByIntro": "Referenced by:",
"error": "Error",
"success": "Success",
"none": "None",
@@ -960,6 +981,9 @@
"renamed": "Album was renamed",
"deleted": "Album was deleted",
"sharingChanged": "Album sharing toggled",
"actionSuccess": "Scheduled action completed",
"actionPartial": "Scheduled action partially succeeded",
"actionFailed": "Scheduled action failed",
"newestFirst": "Most recent events on top",
"oldestFirst": "Oldest events on top",
"chatActionNone": "No indicator shown",
@@ -1021,6 +1045,7 @@
"name": "Name",
"schedule": "Schedule",
"interval": "Interval",
"cronMode": "Cron expression",
"seconds": "seconds",
"cronHint": "Standard cron expression (e.g. 0 3 * * * for daily at 3 AM)",
"enabled": "Enabled",
@@ -1126,6 +1151,18 @@
"savedFiles": "Saved Backups",
"noFiles": "No backup files yet.",
"download": "Download",
"fileDeleted": "Backup file deleted"
"fileDeleted": "Backup file deleted",
"createManual": "Create backup",
"manualCreated": "Backup created",
"pendingTitle": "Restore pending — restart to apply",
"pendingBy": "Uploaded by {by}",
"pendingAt": "at {at}",
"pendingCancelled": "Pending restore cancelled",
"restorePrepared": "Restore prepared",
"restoreApplyPrompt": "Apply the restore now (the backend will restart) or later on the next natural restart?",
"applyLater": "Apply later",
"restartNow": "Restart now",
"restartingTitle": "Restarting backend…",
"restartingDescription": "The page will reload once the server is back online."
}
}
+39 -2
View File
@@ -64,6 +64,8 @@
"activeTrackers": "Активные трекеры",
"targets": "Получатели",
"recentEvents": "События",
"clearEvents": "Очистить",
"confirmClearEvents": "Удалить все записи журнала событий? Это действие нельзя отменить.",
"chart": "График событий",
"noEvents": "Событий пока нет. Создайте трекер для отслеживания.",
"loading": "Загрузка...",
@@ -76,6 +78,9 @@
"collectionRenamed": "альбом переименован",
"collectionDeleted": "альбом удалён",
"sharingChanged": "изменение доступа",
"actionSuccess": "действие выполнено",
"actionPartial": "действие частично",
"actionFailed": "действие провалено",
"searchEvents": "Поиск событий...",
"allEvents": "Все события",
"filterAssetsAdded": "Добавление файлов",
@@ -83,6 +88,9 @@
"filterRenamed": "Переименование",
"filterDeleted": "Удаление",
"filterSharingChanged": "Изменение доступа",
"filterActionSuccess": "Действие выполнено",
"filterActionPartial": "Действие частично",
"filterActionFailed": "Действие провалено",
"allProviders": "Все провайдеры",
"newestFirst": "Сначала новые",
"oldestFirst": "Сначала старые",
@@ -365,6 +373,7 @@
"roleAdmin": "Администратор",
"create": "Создать",
"delete": "Удалить",
"edit": "Редактировать пользователя",
"confirmDelete": "Удалить этого пользователя?",
"joined": "зарегистрирован",
"noUsers": "Пользователи не найдены"
@@ -785,13 +794,21 @@
"disabled": "Отключён",
"noListeners": "Нет подключённых слушателей.",
"selectBot": "Выберите бота...",
"listenerType": "telegram_bot"
"listenerType": "telegram_bot",
"editScope": "Изменить область альбомов",
"scopeAll": "все альбомы",
"albumsShort": "альбомов",
"scopeTitle": "Область альбомов для этого чата",
"scopeDescription": "Ограничить, какие отслеживаемые альбомы доступны в этом чате через команды. Оставьте \"наследовать\", чтобы разрешить все альбомы трекера.",
"scopeInherit": "Наследовать: разрешить все отслеживаемые альбомы",
"noCollections": "Нет доступных альбомов."
},
"snackbar": {
"showDetails": "Показать детали",
"hideDetails": "Скрыть детали"
},
"snack": {
"eventsCleared": "Очищено событий: {count}",
"providerSaved": "Провайдер сохранён",
"providerDeleted": "Провайдер удалён",
"trackerCreated": "Трекер создан",
@@ -810,6 +827,7 @@
"botDeleted": "Бот удалён",
"userCreated": "Пользователь создан",
"userDeleted": "Пользователь удалён",
"userUpdated": "Пользователь обновлён",
"passwordChanged": "Пароль изменён",
"copied": "Скопировано",
"genericError": "Что-то пошло не так",
@@ -827,6 +845,7 @@
"commandTrackerDisabled": "Трекер команд отключён",
"listenerAdded": "Слушатель добавлен",
"listenerRemoved": "Слушатель удалён",
"listenerScopeSaved": "Область обновлена",
"cmdTemplateSaved": "Шаблон команд сохранён",
"cmdTemplateDeleted": "Шаблон команд удалён",
"emailBotCreated": "Email бот создан",
@@ -848,6 +867,8 @@
"description": "Описание",
"close": "Закрыть",
"confirm": "Подтвердить",
"cannotDelete": "Невозможно удалить",
"blockedByIntro": "На объект ссылаются:",
"error": "Ошибка",
"success": "Успешно",
"none": "Нет",
@@ -960,6 +981,9 @@
"renamed": "Альбом переименован",
"deleted": "Альбом удалён",
"sharingChanged": "Изменён доступ к альбому",
"actionSuccess": "Запланированное действие выполнено",
"actionPartial": "Запланированное действие выполнено частично",
"actionFailed": "Запланированное действие провалено",
"newestFirst": "Сначала новые события",
"oldestFirst": "Сначала старые события",
"chatActionNone": "Индикатор не показывается",
@@ -1021,6 +1045,7 @@
"name": "Название",
"schedule": "Расписание",
"interval": "Интервал",
"cronMode": "Cron выражение",
"seconds": "секунд",
"cronHint": "Стандартное cron-выражение (напр. 0 3 * * * — ежедневно в 3:00)",
"enabled": "Включено",
@@ -1126,6 +1151,18 @@
"savedFiles": "Сохранённые бэкапы",
"noFiles": "Файлов бэкапа пока нет.",
"download": "Скачать",
"fileDeleted": "Файл бэкапа удалён"
"fileDeleted": "Файл бэкапа удалён",
"createManual": "Создать бэкап",
"manualCreated": "Бэкап создан",
"pendingTitle": "Восстановление ожидает — перезапустите для применения",
"pendingBy": "Загружено пользователем {by}",
"pendingAt": "в {at}",
"pendingCancelled": "Ожидающее восстановление отменено",
"restorePrepared": "Восстановление подготовлено",
"restoreApplyPrompt": "Применить восстановление сейчас (бэкенд перезапустится) или позже при следующем штатном перезапуске?",
"applyLater": "Применить позже",
"restartNow": "Перезапустить сейчас",
"restartingTitle": "Перезапуск бэкенда…",
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен."
}
}
+17 -1
View File
@@ -105,7 +105,10 @@ export const immichDescriptor: ProviderDescriptor = {
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
const warnings: { id: string; name: string; issue: string }[] = [];
for (const albumId of newIds) {
// Run shared-link checks in parallel with a concurrency cap so a large
// album set doesn't stall the save button for seconds.
const CONCURRENCY = 6;
async function checkOne(albumId: string): Promise<void> {
try {
const links = await apiFn<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
const validLink = links.find((l) => l.is_accessible && !l.is_expired);
@@ -123,6 +126,19 @@ export const immichDescriptor: ProviderDescriptor = {
} catch { /* shared-link check failed, proceed */ }
}
const queue = [...newIds];
const workers: Promise<void>[] = [];
for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) {
workers.push((async () => {
while (queue.length > 0) {
const next = queue.shift();
if (next === undefined) return;
await checkOne(next);
}
})());
}
await Promise.all(workers);
if (warnings.length > 0) return { warnings, proceed: false };
return { proceed: true };
},
+2 -2
View File
@@ -384,7 +384,7 @@
</div>
<!-- Global provider filter -->
{#if allProviders.length > 1}
{#if allProviders.length >= 1}
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
{#if collapsed}
<button onclick={() => {
@@ -564,7 +564,7 @@
onclick={() => mobileMoreOpen = false} role="presentation"></div>
<div class="mobile-more-panel" style="position: fixed; bottom: 3.25rem; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: 60vh; overflow-y: auto;"
transition:slide={{ duration: 200, easing: cubicOut }}>
{#if allProviders.length > 1}
{#if allProviders.length >= 1}
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
</div>
+44 -7
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -11,6 +11,8 @@
import EventChart from '$lib/components/EventChart.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers';
@@ -56,6 +58,21 @@
let eventsLimit = $state(loadEventsPerPage());
let eventsOffset = $state(0);
let eventsLoading = $state(false);
let confirmClearEvents = $state(false);
async function clearEvents() {
try {
const res = await api<{ deleted: number }>('/status/events', { method: 'DELETE' });
snackSuccess(t('snack.eventsCleared').replace('{count}', String(res.deleted)));
eventsOffset = 0;
await loadEvents();
await loadChart();
} catch (err: any) {
snackError(err.message || t('common.error'));
} finally {
confirmClearEvents = false;
}
}
let currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1);
let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
@@ -191,7 +208,7 @@
] : []);
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const diff = Date.now() - parseDate(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return t('dashboard.justNow');
if (mins < 60) return t('dashboard.minutesAgo').replace('{n}', String(mins));
@@ -206,15 +223,20 @@
collection_renamed: 'dashboard.collectionRenamed',
collection_deleted: 'dashboard.collectionDeleted',
sharing_changed: 'dashboard.sharingChanged',
action_success: 'dashboard.actionSuccess',
action_partial: 'dashboard.actionPartial',
action_failed: 'dashboard.actionFailed',
};
const eventIcons: Record<string, string> = {
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
};
const eventColors: Record<string, string> = {
assets_added: '#059669', assets_removed: '#ef4444',
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
action_success: '#0d9488', action_partial: '#f59e0b', action_failed: '#dc2626',
};
</script>
@@ -252,13 +274,23 @@
</div>
<!-- Events section -->
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
<MdiIcon name="mdiPulse" size={18} />
{t('dashboard.recentEvents')}
<div class="flex items-center justify-between mb-3">
<h3 class="text-base font-semibold flex items-center gap-2">
<MdiIcon name="mdiPulse" size={18} />
{t('dashboard.recentEvents')}
{#if status.total_events > 0}
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
{/if}
</h3>
{#if status.total_events > 0}
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
<button type="button" onclick={() => confirmClearEvents = true}
class="clear-events-btn flex items-center gap-1.5 px-2.5 py-1 text-xs border border-[var(--color-border)] rounded-md transition-colors"
title={t('dashboard.clearEvents')}>
<MdiIcon name="mdiTrashCanOutline" size={14} />
<span class="hidden sm:inline">{t('dashboard.clearEvents')}</span>
</button>
{/if}
</h3>
</div>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-2 mb-4">
@@ -370,6 +402,9 @@
{/if}
{/if}
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
<style>
.stat-card { position: relative; border-radius: 0.75rem; padding: 1px; background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border)); transition: all 0.3s ease; }
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); }
@@ -385,4 +420,6 @@
.event-content { flex: 1; min-width: 0; padding: 0.375rem 0.75rem; border-radius: 0.5rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
.event-content:hover { border-color: var(--color-primary); box-shadow: 0 0 12px var(--color-glow); }
.event-badge { display: inline-block; font-size: 0.65rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.15rem 0.5rem; border-radius: 9999px; background: var(--color-muted); color: var(--color-muted-foreground); white-space: nowrap; font-family: var(--font-mono); }
.clear-events-btn { color: var(--color-muted-foreground); background: transparent; }
.clear-events-btn:hover { background: color-mix(in srgb, var(--color-error-fg) 10%, transparent); border-color: color-mix(in srgb, var(--color-error-fg) 40%, var(--color-border)); color: var(--color-error-fg); }
</style>
@@ -1,5 +1,5 @@
<script lang="ts">
import { api } from '$lib/api';
import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import type { ActionExecution } from '$lib/types';
@@ -47,14 +47,14 @@
function formatDate(iso: string | null): string {
if (!iso) return '-';
try {
return new Date(iso).toLocaleString();
return parseDate(iso).toLocaleString();
} catch { return iso; }
}
function formatDuration(start: string, end: string | null): string {
if (!end) return '-';
try {
const ms = new Date(end).getTime() - new Date(start).getTime();
const ms = parseDate(end).getTime() - parseDate(start).getTime();
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
} catch { return '-'; }
+10 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { emailBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -58,12 +59,17 @@
finally { emailSubmitting = false; }
}
let blockedBy = $state<BlockedByDetail | null>(null);
function removeEmail(id: number) {
confirmDeleteEmail = {
id,
onconfirm: async () => {
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDeleteEmail = null; }
}
};
@@ -173,3 +179,5 @@
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
+10 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { matrixBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -56,12 +57,17 @@
finally { matrixSubmitting = false; }
}
let blockedBy = $state<BlockedByDetail | null>(null);
function removeMatrix(id: number) {
confirmDeleteMatrix = {
id,
onconfirm: async () => {
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDeleteMatrix = null; }
}
};
@@ -155,3 +161,5 @@
<ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')}
onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
+10 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { telegramBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -64,12 +65,17 @@
finally { submitting = false; }
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDelete = null; }
}
};
@@ -518,3 +524,5 @@
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -92,7 +93,10 @@
function openNew() {
form = defaultForm();
// Auto-select first matching template for the default provider_type
// Auto-select first provider type with commands
const types = Object.keys(allCapabilities).filter(t => (allCapabilities[t]?.commands?.length || 0) > 0);
if (types.length > 0) form.provider_type = types[0];
// Auto-select first matching template for the chosen provider_type
const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
if (match) form.command_template_config_id = match.id;
editing = null;
@@ -137,6 +141,7 @@
finally { submitting = false; }
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(cfg: CommandConfig) {
confirmDelete = {
id: cfg.id,
@@ -145,7 +150,11 @@
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.commandConfigDeleted'));
} catch (err: any) { snackError(err.message); }
} catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
snackError(err.message);
}
finally { confirmDelete = null; }
}
};
@@ -296,3 +305,5 @@
<ConfirmModal open={confirmDelete !== null} message={t('commandConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize';
import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
@@ -189,6 +190,8 @@
function openNew() {
form = defaultForm();
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
editing = null;
showForm = true;
activeLocale = 'en';
@@ -265,6 +268,7 @@
setTimeout(() => refreshAllPreviews(), 100);
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) {
confirmDelete = {
id,
@@ -274,6 +278,8 @@
await load();
snackSuccess(t('snack.cmdTemplateDeleted'));
} catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message;
snackError(err.message);
} finally {
@@ -458,6 +464,8 @@
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]}
@@ -11,6 +11,7 @@
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 IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
@@ -83,7 +84,17 @@
finally { loaded = true; highlightFromUrl(); }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; }
function openNew() {
form = defaultForm();
if (providers.length > 0) form.provider_id = providers[0].id;
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
if (ptype) {
const firstCfg = commandConfigs.find(c => c.provider_type === ptype);
if (firstCfg) form.command_config_id = firstCfg.id;
}
editing = null;
showForm = true;
}
function editTracker(trk: any) {
form = {
name: trk.name,
@@ -178,6 +189,35 @@
} catch (err: any) { snackError(err.message); }
}
// Per-listener album scope editing
let scopeEditor = $state<{ trkId: number; listener: any; providerId: number; collections: any[]; selectedIds: string[]; inherit: boolean } | null>(null);
async function openScopeEditor(trkId: number, listener: any) {
const trk = allCmdTrackers.find((t: any) => t.id === trkId);
if (!trk) return;
let collections: any[] = [];
try { collections = await api(`/providers/${trk.provider_id}/collections`); } catch { /* ignore */ }
scopeEditor = {
trkId,
listener,
providerId: trk.provider_id,
collections,
selectedIds: [...(listener.allowed_album_ids || [])],
inherit: listener.allowed_album_ids === null || listener.allowed_album_ids === undefined,
};
}
async function saveScope() {
if (!scopeEditor) return;
const body = { allowed_album_ids: scopeEditor.inherit ? null : scopeEditor.selectedIds };
try {
await api(`/command-trackers/${scopeEditor.trkId}/listeners/${scopeEditor.listener.id}`, {
method: 'PATCH', body: JSON.stringify(body),
});
snackSuccess(t('snack.listenerScopeSaved'));
await loadListeners(scopeEditor.trkId);
scopeEditor = null;
} catch (err: any) { snackError(err.message); }
}
function providerName(id: number): string {
return providers.find(p => p.id === id)?.name || '?';
}
@@ -289,10 +329,18 @@
<div class="space-y-1">
{#each listeners[trk.id] as listener}
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 min-w-0">
<MdiIcon name="mdiRobot" size={14} />
<CrossLink href="/bots?tab=telegram" icon="mdiRobot" label={listener.name || listener.listener_type} entityId={listener.listener_id} />
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-mono">{listener.listener_type}</span>
<button type="button" onclick={() => openScopeEditor(trk.id, listener)}
class="flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]"
title={t('commandTracker.editScope')}>
<MdiIcon name="mdiImageMultiple" size={12} />
{listener.allowed_album_ids === null || listener.allowed_album_ids === undefined
? t('commandTracker.scopeAll')
: `${(listener.allowed_album_ids || []).length} ${t('commandTracker.albumsShort')}`}
</button>
</div>
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
onclick={() => removeListener(trk.id, listener.id)} variant="danger" />
@@ -321,3 +369,59 @@
<ConfirmModal open={confirmDelete !== null} message={t('commandTracker.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<!-- Per-listener album scope editor -->
<Modal open={scopeEditor !== null} title={t('commandTracker.scopeTitle')} onclose={() => scopeEditor = null}>
{#if scopeEditor}
<p class="text-xs text-[var(--color-muted-foreground)] mb-3">{t('commandTracker.scopeDescription')}</p>
<label class="flex items-center gap-2 text-sm mb-3">
<input type="checkbox" bind:checked={scopeEditor.inherit} />
{t('commandTracker.scopeInherit')}
</label>
{#if !scopeEditor.inherit}
{#if scopeEditor.collections.length > 0}
<div class="flex items-center justify-between mb-1.5 text-xs" style="color: var(--color-muted-foreground);">
<span>{scopeEditor.selectedIds.length} / {scopeEditor.collections.length}</span>
<div class="flex items-center gap-2">
<button type="button" class="underline hover:text-[var(--color-primary)]"
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = scopeEditor.collections.map((c: any) => c.id); }}>
{t('backup.selectAll')}
</button>
<span aria-hidden="true">·</span>
<button type="button" class="underline hover:text-[var(--color-primary)]"
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = []; }}>
{t('backup.deselectAll')}
</button>
</div>
</div>
{/if}
<div class="space-y-1 max-h-72 overflow-y-auto border border-[var(--color-border)] rounded-md p-2">
{#if scopeEditor.collections.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] py-3 text-center">{t('commandTracker.noCollections')}</p>
{:else}
{#each scopeEditor.collections as col}
{@const cid = col.id}
<label class="flex items-center gap-2 text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)] cursor-pointer">
<input type="checkbox" checked={scopeEditor.selectedIds.includes(cid)}
onchange={(e) => {
if (!scopeEditor) return;
const target = e.target as HTMLInputElement;
scopeEditor.selectedIds = target.checked
? [...scopeEditor.selectedIds, cid]
: scopeEditor.selectedIds.filter((i) => i !== cid);
}} />
<span class="truncate min-w-0 flex-1" title={col.albumName || col.name || cid}>{col.albumName || col.name || cid}</span>
</label>
{/each}
{/if}
</div>
{/if}
<div class="flex gap-2 justify-end mt-4">
<button onclick={() => scopeEditor = null}
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
{t('common.cancel')}
</button>
<Button size="sm" onclick={saveScope}>{t('common.save')}</Button>
</div>
{/if}
</Modal>
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { api, parseDate } from '$lib/api';
import { t, getLocale } from '$lib/i18n';
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -136,10 +136,29 @@
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
_prevProviderId = form.provider_id;
loadCollections();
// Auto-select first available tracking/template config for this provider when creating
if (editing === null) {
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
if (ptype) {
if (!form.default_tracking_config_id) {
const first = trackingConfigs.find(c => c.provider_type === ptype);
if (first) form.default_tracking_config_id = first.id;
}
if (!form.default_template_config_id) {
const first = templateConfigs.find(c => c.provider_type === ptype);
if (first) form.default_template_config_id = first.id;
}
}
}
}
});
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; previousCollectionIds = []; }
function openNew() {
form = defaultForm();
// Auto-select first provider if any
if (providers.length > 0) form.provider_id = providers[0].id;
editing = null; showForm = true; collections = []; previousCollectionIds = [];
}
async function edit(trk: Tracker) {
form = {
@@ -256,7 +275,7 @@
function formatDate(dateStr: string): string {
if (!dateStr) return '';
try {
const d = new Date(dateStr);
const d = parseDate(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} catch (e) { console.warn('Date format error:', e); return ''; }
}
+10 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -11,6 +11,7 @@
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
@@ -131,12 +132,17 @@
}
function startDelete(provider: any) { confirmDelete = provider; }
let blockedBy = $state<BlockedByDetail | null>(null);
async function doDelete() {
if (!confirmDelete) return;
const id = confirmDelete.id;
confirmDelete = null;
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
}
</script>
@@ -280,6 +286,8 @@
<ConfirmModal open={!!confirmDelete} message={t('providers.confirmDelete')}
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style>
.health-dot {
width: 10px;
@@ -1,6 +1,6 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { api } from '$lib/api';
import { api, parseDate } from '$lib/api';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import type { WebhookPayloadLog } from '$lib/types';
@@ -65,7 +65,7 @@
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleString();
return parseDate(iso).toLocaleString();
}
</script>
+164 -24
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { api, fetchAuth } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
@@ -60,15 +59,23 @@
let backupFiles = $state<any[]>([]);
let loadingFiles = $state(false);
let confirmDeleteFile = $state('');
let creatingBackup = $state(false);
// --- Pending restore state ---
let pending = $state<{ pending: boolean; uploaded_at?: string | null; uploaded_by?: string | null; conflict_mode?: string; supervised?: boolean } | null>(null);
let postRestoreModalOpen = $state(false);
let restartingOverlay = $state(false);
onMount(async () => {
try {
const [settings, files] = await Promise.all([
const [settings, files, p] = await Promise.all([
api('/backup/scheduled'),
api('/backup/files'),
api('/backup/pending-restore'),
]);
scheduledSettings = settings;
backupFiles = files;
pending = p;
} catch (err: any) {
error = err.message;
snackError(err.message);
@@ -77,6 +84,53 @@
}
});
async function cancelPending() {
try {
await api('/backup/pending-restore', { method: 'DELETE' });
snackSuccess(t('backup.pendingCancelled'));
pending = null;
} catch (err: any) { snackError(err.message); }
}
async function applyAndRestart() {
try {
await api('/backup/apply-restart', { method: 'POST' });
restartingOverlay = true;
// Poll /health until the new instance is up
const startedAt = Date.now();
let attempts = 0;
const poll = async () => {
attempts += 1;
try {
const res = await fetch('/api/health');
if (res.ok && Date.now() - startedAt > 2000) {
window.location.reload();
return;
}
} catch { /* still down */ }
if (attempts < 120) setTimeout(poll, 1000);
};
setTimeout(poll, 1500);
} catch (err: any) {
restartingOverlay = false;
snackError(err.message);
}
}
async function createManualBackup() {
creatingBackup = true;
try {
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
snackSuccess(t('backup.manualCreated'));
await refreshFiles();
} catch (err: any) {
snackError(err.message);
} finally {
creatingBackup = false;
}
}
// --- Export ---
async function doExport() {
if (exportSecrets === 'include') {
@@ -120,16 +174,7 @@
try {
const formData = new FormData();
formData.append('file', importFile);
const token = localStorage.getItem('access_token');
const res = await fetch('/api/backup/validate', {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
validationResult = await res.json();
} catch (err: any) {
snackError(err.message);
@@ -151,18 +196,15 @@
try {
const formData = new FormData();
formData.append('file', importFile);
const token = localStorage.getItem('access_token');
const res = await fetch(`/api/backup/import?conflict_mode=${importConflict}`, {
const res = await fetchAuth(`/backup/prepare-restore?conflict_mode=${importConflict}`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
importResult = await res.json();
snackSuccess(t('backup.importSuccess'));
pending = importResult;
snackSuccess(t('backup.restorePrepared'));
postRestoreModalOpen = true;
importFile = null;
} catch (err: any) {
snackError(err.message);
} finally {
@@ -256,6 +298,33 @@
<Loading />
{:else}
<ErrorBanner message={error} />
{#if pending?.pending}
<div class="mb-4 p-3 rounded-lg flex flex-wrap items-center gap-3 pending-banner"
style="border: 1px solid color-mix(in srgb, var(--color-warning-fg) 40%, transparent); background: color-mix(in srgb, var(--color-warning-bg) 60%, transparent);">
<span style="color: var(--color-warning-fg); flex-shrink: 0;">
<MdiIcon name="mdiClockAlert" size={20} />
</span>
<div class="flex-1 min-w-[12rem] text-sm">
<div class="font-medium">{t('backup.pendingTitle')}</div>
<div class="text-xs break-words" style="color: var(--color-muted-foreground);">
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '')} · {t('backup.pendingAt').replace('{at}', pending.uploaded_at || '')}
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
{#if pending.supervised}
<Button size="sm" onclick={applyAndRestart}>
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
</Button>
{/if}
<button onclick={cancelPending}
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
{t('common.cancel')}
</button>
</div>
</div>
{/if}
<div class="space-y-6">
<!-- Export Section -->
@@ -502,9 +571,14 @@
<MdiIcon name="mdiFolder" size={18} />
{t('backup.savedFiles')}
</h3>
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
<MdiIcon name="mdiRefresh" size={14} />
</button>
<div class="flex items-center gap-2">
<Button size="sm" onclick={createManualBackup} disabled={creatingBackup}>
<MdiIcon name="mdiPlus" size={14} /> {creatingBackup ? t('common.loading') : t('backup.createManual')}
</Button>
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
<MdiIcon name="mdiRefresh" size={14} />
</button>
</div>
</div>
{#if backupFiles.length === 0}
@@ -568,3 +642,69 @@
onconfirm={() => deleteFile(confirmDeleteFile)}
oncancel={() => confirmDeleteFile = ''}
/>
<!-- Post-restore modal: Apply now or later -->
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
{#if postRestoreModalOpen && pending?.pending}
<div class="post-restore-backdrop"
style="position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.5); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 1rem;"
onclick={() => postRestoreModalOpen = false}
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
role="presentation">
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1"
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem; max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.4);"
onclick={(e) => e.stopPropagation()}>
<div class="flex items-start gap-3 mb-4">
<div class="flex items-center justify-center w-10 h-10 rounded-full flex-shrink-0"
style="background: var(--color-warning-bg); color: var(--color-warning-fg);">
<MdiIcon name="mdiClockAlert" size={22} />
</div>
<div class="min-w-0">
<h3 id="post-restore-title" class="font-semibold mb-1">{t('backup.restorePrepared')}</h3>
<p class="text-sm break-words" style="color: var(--color-muted-foreground);">{t('backup.restoreApplyPrompt')}</p>
</div>
</div>
<div class="flex gap-2 justify-end flex-wrap">
<button onclick={() => postRestoreModalOpen = false}
class="px-3 py-2 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
{t('backup.applyLater')}
</button>
{#if pending.supervised}
<Button size="sm" onclick={() => { postRestoreModalOpen = false; applyAndRestart(); }}>
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
</Button>
{/if}
</div>
</div>
</div>
{/if}
<!-- Restarting overlay -->
{#if restartingOverlay}
<div role="alert" aria-live="assertive"
style="position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); padding: 1rem;">
<div class="text-center p-6" style="color: var(--color-foreground);">
<div class="restart-spinner" style="color: var(--color-primary); margin-bottom: 1rem;">
<MdiIcon name="mdiRestart" size={40} />
</div>
<p class="text-lg font-semibold">{t('backup.restartingTitle')}</p>
<p class="text-sm mt-2" style="color: var(--color-muted-foreground);">{t('backup.restartingDescription')}</p>
</div>
</div>
{/if}
<style>
.restart-spinner {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
animation: restart-spin 1.2s linear infinite;
transform-origin: center center;
}
@keyframes restart-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
+12 -2
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { page } from '$app/state';
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -114,7 +115,7 @@
const defaultForm = () => ({
name: '', icon: '', bot_id: 0, bot_token: '',
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
disable_url_preview: true, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
// Discord/Slack shared settings
username: '',
// ntfy shared settings
@@ -193,6 +194,10 @@
function openNew() {
form = defaultForm();
formType = activeType || 'telegram';
// Auto-select first available bot of the chosen type
if (formType === 'telegram' && telegramBots.length > 0) form.bot_id = telegramBots[0].id;
if (formType === 'email' && emailBots.length > 0) form.email_bot_id = emailBots[0].id;
if (formType === 'matrix' && matrixBots.length > 0) form.matrix_bot_id = matrixBots[0].id;
editing = null;
showTelegramSettings = false;
showForm = true;
@@ -289,12 +294,15 @@
} catch (err: any) { snackError(err.message); }
}
let blockedBy = $state<BlockedByDetail | null>(null);
async function remove(id: number) {
try {
await api(`/targets/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetDeleted'));
} catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message;
snackError(err.message);
}
@@ -529,3 +537,5 @@
onconfirm={() => { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }}
oncancel={() => confirmDeleteReceiver = null}
/>
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize';
import { templateConfigsCache, capabilitiesCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
@@ -208,7 +209,12 @@
finally { loaded = true; highlightFromUrl(); }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = ''; refreshDateFormatPreview(); }
function openNew() {
form = defaultForm();
if (providerTypes.length > 0) form.provider_type = providerTypes[0];
editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
refreshDateFormatPreview();
}
function edit(c: TemplateConfig) {
form = {
provider_type: c.provider_type,
@@ -253,12 +259,17 @@
setTimeout(() => refreshAllPreviews(), 100);
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDelete = null; }
}
};
@@ -455,6 +466,8 @@
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: {showVarsFor ? t(`templateConfig.${templateSlots.flatMap(g => g.slots).find(s => s.key === showVarsFor)?.label || showVarsFor}`) : ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]}
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { trackingConfigsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -72,12 +73,17 @@
} catch (err: any) { error = err.message; snackError(err.message); }
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDelete = null; }
}
};
@@ -257,6 +263,8 @@
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style>
.toggle-switch {
position: relative;
+47 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n';
import { getAuth } from '$lib/auth.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -31,6 +31,13 @@
let resetMsg = $state('');
let resetSuccess = $state(false);
// Admin edit username/role
let editUserId = $state<number | null>(null);
let editUsername = $state('');
let editRole = $state('user');
let editMsg = $state('');
let editSuccess = $state(false);
onMount(load);
async function load() {
try { users = await api('/users'); }
@@ -56,6 +63,20 @@
function openResetPassword(user: any) {
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
}
function openEditUser(user: any) {
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
}
async function saveUserEdit(e: SubmitEvent) {
e.preventDefault(); editMsg = ''; editSuccess = false;
try {
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
editMsg = t('snack.userUpdated');
editSuccess = true;
snackSuccess(editMsg);
await load();
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
} catch (err: any) { editMsg = err.message; editSuccess = false; snackError(err.message); }
}
async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = ''; resetSuccess = false;
try {
@@ -111,9 +132,10 @@
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {new Date(user.created_at).toLocaleDateString()}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
{#if user.id !== auth.user?.id}
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
@@ -144,5 +166,28 @@
</form>
</Modal>
<!-- Admin edit username/role modal -->
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
<form onsubmit={saveUserEdit} class="space-y-3">
<div>
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
<input id="edit-username" bind:value={editUsername} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
<select id="edit-role" bind:value={editRole}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">{t('users.roleUser')}</option>
<option value="admin">{t('users.roleAdmin')}</option>
</select>
</div>
{#if editMsg}
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
{/if}
<Button type="submit" class="w-full">{t('common.save')}</Button>
</form>
</Modal>
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
@@ -16,6 +16,13 @@ from .ssrf import UnsafeURLError, validate_outbound_url
_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30)
# Cap on how many asset downloads run concurrently inside
# ``_preload_asset_data``. Peak memory during a send is bounded to roughly
# ``_PRELOAD_CONCURRENCY * max_asset_size`` instead of ``max_media_to_send *
# max_asset_size``, which matters on small-RAM Docker hosts when a batch
# contains many large videos.
_PRELOAD_CONCURRENCY = 6
def _new_session() -> aiohttp.ClientSession:
"""Per-dispatch aiohttp session with a sane default timeout.
@@ -38,6 +45,11 @@ from .receiver import (
)
from .telegram.cache import TelegramFileCache
from .telegram.client import TelegramClient
from .telegram.media import (
extract_asset_id_from_url,
is_asset_cache_key,
is_asset_id,
)
from .webhook.client import WebhookClient
_LOGGER = logging.getLogger(__name__)
@@ -146,6 +158,90 @@ class NotificationDispatcher:
return await send_method(target, default_message, event)
return {"success": False, "error": f"Unknown target type: {target.type}"}
async def _preload_asset_data(
self,
assets: list[dict[str, Any]],
media_assets: list[Any],
session: aiohttp.ClientSession,
max_size: int | None,
) -> None:
"""Download each non-cached asset's bytes once and attach to the entry.
Three benefits:
* ``TelegramClient`` sees ``entry["data"]`` and skips its own download,
so we don't fetch each URL twice.
* We know the exact upload size, which lets the oversize warning in
the rendered text compare against real bytes (for Immich videos,
the transcoded ``/video/playback``), not the original ``file_size``.
* Assets already in the Telegram file_id cache are skipped, and their
stored size (if any) is used to populate ``playback_size`` so
templates see consistent sizes for repeat sends without re-download.
Entries whose download fails or exceeds ``max_size`` are left without
``data``; ``TelegramClient`` will then fall back to its own download
path and apply the same checks no regression, just no preload win.
Concurrency is bounded by ``_PRELOAD_CONCURRENCY`` so peak memory
stays predictable: at most N assets worth of bytes held in RAM at
once, regardless of ``max_media_to_send``. Total wall-clock is
unchanged for small batches and only marginally slower for large
ones (most assets fit in a single RTT and SSL negotiation cost
dominates, so 6-way parallelism is sufficient).
"""
if not assets:
return
sem = asyncio.Semaphore(_PRELOAD_CONCURRENCY)
async def _fetch(entry: dict[str, Any], media: Any) -> None:
# Cache hit → skip download; populate playback_size from stored size.
cache, key = self._cache_for_entry(entry)
if cache and key:
cached = cache.get(key)
if cached and cached.get("file_id"):
stored_size = cached.get("size")
if stored_size is not None:
media.extra["playback_size"] = stored_size
return
url = entry["url"]
headers = entry.get("headers") or {}
async with sem:
try:
async with session.get(url, headers=headers) as resp:
if resp.status != 200:
return
data = await resp.read()
except aiohttp.ClientError:
return
if max_size is not None and len(data) > max_size:
return
entry["data"] = data
media.extra["playback_size"] = len(data)
await asyncio.gather(*(_fetch(e, m) for e, m in zip(assets, media_assets)))
def _cache_for_entry(
self, entry: dict[str, Any],
) -> tuple[TelegramFileCache | None, str | None]:
"""Resolve (cache, key) for an asset entry — mirrors TelegramClient logic.
Returns (None, None) if no cache is configured or no key can be derived.
"""
cache_key = entry.get("cache_key")
if cache_key:
cache = self._asset_cache if is_asset_cache_key(cache_key) else self._url_cache
return cache, cache_key
url = entry.get("url")
if url:
if is_asset_id(url):
return self._asset_cache, url
extracted = extract_asset_id_from_url(url)
if extracted:
return self._asset_cache, extracted
return self._url_cache, url
return None, None
async def _send_telegram(
self, target: TargetConfig, default_message: str, event: ServiceEvent
) -> dict[str, Any]:
@@ -172,6 +268,7 @@ class NotificationDispatcher:
external_url = (target.provider_external_url or "").rstrip("/")
provider_urls = [u for u in (internal_url, external_url) if u]
assets = []
media_assets: list[Any] = [] # aligned with `assets` for preload
for asset in event.added_assets[:max_media]:
url = asset.preview_url or asset.thumbnail_url or asset.full_url
if url:
@@ -187,9 +284,16 @@ class NotificationDispatcher:
if asset.extra.get("cache_key"):
asset_entry["cache_key"] = asset.extra["cache_key"]
assets.append(asset_entry)
media_assets.append(asset)
results: list[dict[str, Any]] = []
async with _new_session() as session:
# Preload all asset bytes once so (a) TelegramClient can skip its
# own download and (b) we know exact upload sizes in time for the
# oversize warning in the rendered text.
await self._preload_asset_data(assets, media_assets, session, max_size)
default_message = self._render_message(event, target, target.locale)
client = TelegramClient(
session, bot_token,
url_cache=self._url_cache,
@@ -84,10 +84,19 @@ class TelegramFileCache:
if age > self._ttl_seconds:
return None
return {"file_id": entry.get("file_id"), "type": entry.get("type")}
return {
"file_id": entry.get("file_id"),
"type": entry.get("type"),
"size": entry.get("size"), # bytes of what was uploaded; None for legacy entries
}
async def async_set(
self, key: str, file_id: str, media_type: str, thumbhash: str | None = None
self,
key: str,
file_id: str,
media_type: str,
thumbhash: str | None = None,
size: int | None = None,
) -> None:
if self._data is None:
self._data = {"files": {}}
@@ -99,20 +108,34 @@ class TelegramFileCache:
}
if thumbhash is not None:
entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
self._data["files"][key] = entry
await self._backend.save(self._data)
async def async_set_many(
self, entries: list[tuple[str, str, str, str | None]]
self,
entries: list[tuple[str, str, str, str | None] | tuple[str, str, str, str | None, int | None]],
) -> None:
"""Bulk-store file_id cache entries.
Each entry is a tuple ``(key, file_id, media_type, thumbhash[, size])``.
The size element is optional for backward compatibility with callers
that don't yet track upload sizes.
"""
if not entries:
return
if self._data is None:
self._data = {"files": {}}
now_iso = datetime.now(timezone.utc).isoformat()
for key, file_id, media_type, thumbhash in entries:
for item in entries:
if len(item) == 5:
key, file_id, media_type, thumbhash, size = item
else:
key, file_id, media_type, thumbhash = item
size = None
entry: dict[str, Any] = {
"file_id": file_id,
"type": media_type,
@@ -120,6 +143,8 @@ class TelegramFileCache:
}
if thumbhash is not None:
entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
self._data["files"][key] = entry
await self._backend.save(self._data)
@@ -6,6 +6,7 @@ import asyncio
import json
import logging
import mimetypes
from dataclasses import dataclass
from typing import Any, Callable
import aiohttp
@@ -29,6 +30,36 @@ _LOGGER = logging.getLogger(__name__)
NotificationResult = dict[str, Any]
@dataclass(frozen=True)
class _MediaKind:
"""Describes one Telegram media kind (photo / video / document).
Used by the generic _send_from_cache / _upload_media helpers so the three
send paths don't have to duplicate endpoint, field-name, or response-shape
boilerplate.
"""
api_method: str # "sendPhoto" / "sendVideo" / "sendDocument"
form_field: str # "photo" / "video" / "document"
cache_type: str # same string stored in cache entries
default_filename: str # "photo.jpg" / "video.mp4" / "file"
default_content_type: str
def file_id_from_result(self, result: dict[str, Any]) -> str | None:
obj = result.get(self.form_field)
if isinstance(obj, list) and obj:
# sendPhoto returns a list of resolutions; the largest is last.
last = obj[-1]
return last.get("file_id") if isinstance(last, dict) else None
if isinstance(obj, dict):
return obj.get("file_id")
return None
_PHOTO_KIND = _MediaKind("sendPhoto", "photo", "photo", "photo.jpg", "image/jpeg")
_VIDEO_KIND = _MediaKind("sendVideo", "video", "video", "video.mp4", "video/mp4")
_DOCUMENT_KIND = _MediaKind("sendDocument", "document", "document", "file", "application/octet-stream")
class TelegramClient:
"""Async Telegram Bot API client for sending notifications with media."""
@@ -76,6 +107,94 @@ class TelegramClient:
is_asset = is_asset_cache_key(key)
return self._asset_cache if is_asset else self._url_cache
async def _fetch_bytes(
self,
url: str,
headers: dict[str, str] | None,
preloaded: bytes | None,
) -> tuple[bytes | None, str | None]:
"""Return ``(data, error_msg)``. Uses ``preloaded`` bytes if provided."""
if preloaded is not None:
return preloaded, None
try:
async with self._session.get(self._resolve_url(url), headers=headers or {}) as resp:
if resp.status != 200:
return None, f"HTTP {resp.status}"
return await resp.read(), None
except aiohttp.ClientError as err:
return None, str(err)
async def _send_from_cache(
self,
kind: _MediaKind,
chat_id: str,
file_id: str,
caption: str | None,
reply_to_message_id: int | None,
parse_mode: str,
) -> NotificationResult | None:
"""POST a file_id reference. Return None on transient error so the
caller can fall through to a fresh upload."""
payload: dict[str, Any] = {"chat_id": chat_id, kind.form_field: file_id, "parse_mode": parse_mode}
if caption:
payload["caption"] = caption
if reply_to_message_id:
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{kind.api_method}"
try:
async with self._session.post(telegram_url, json=payload) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
return {
"success": True,
"message_id": result.get("result", {}).get("message_id"),
"cached": True,
}
except aiohttp.ClientError:
pass
return None
async def _upload_media(
self,
kind: _MediaKind,
chat_id: str,
data: bytes,
filename: str,
content_type: str,
caption: str | None,
reply_to_message_id: int | None,
parse_mode: str,
cache: TelegramFileCache | None,
cache_key: str | None,
thumbhash: str | None,
) -> NotificationResult:
"""Multipart-upload ``data`` to Telegram and cache the returned file_id."""
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field(kind.form_field, data, filename=filename, content_type=content_type)
form.add_field("parse_mode", parse_mode)
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{kind.api_method}"
try:
async with self._session.post(telegram_url, data=form) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
res = result.get("result", {})
file_id = kind.file_id_from_result(res)
if file_id and cache and cache_key:
await cache.async_set(
cache_key, file_id, kind.cache_type,
thumbhash=thumbhash, size=len(data),
)
return {"success": True, "message_id": res.get("message_id")}
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def send_notification(
self,
chat_id: str,
@@ -107,6 +226,7 @@ class TelegramClient:
parse_mode, max_asset_data_size, send_large_photos_as_documents,
assets[0].get("content_type"), assets[0].get("cache_key"),
download_headers=assets[0].get("headers"),
preloaded_data=assets[0].get("data"),
)
if len(assets) == 1 and assets[0].get("type") == "video":
return await self._send_video(
@@ -114,28 +234,31 @@ class TelegramClient:
parse_mode, max_asset_data_size,
assets[0].get("content_type"), assets[0].get("cache_key"),
download_headers=assets[0].get("headers"),
preloaded_data=assets[0].get("data"),
)
if len(assets) == 1 and assets[0].get("type", "document") == "document":
url = assets[0].get("url")
if not url:
return {"success": False, "error": "Missing 'url' for document"}
try:
download_url = self._resolve_url(url)
dl_headers = assets[0].get("headers") or {}
async with self._session.get(download_url, headers=dl_headers) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
data = await resp.read()
if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": f"Media size exceeds limit"}
filename = url.split("/")[-1].split("?")[0] or "file"
return await self._send_document(
chat_id, data, filename, caption, reply_to_message_id,
parse_mode, url, assets[0].get("content_type"),
assets[0].get("cache_key"),
)
except aiohttp.ClientError as err:
return {"success": False, "error": f"Failed to download media: {err}"}
data = assets[0].get("data")
if data is None:
try:
download_url = self._resolve_url(url)
dl_headers = assets[0].get("headers") or {}
async with self._session.get(download_url, headers=dl_headers) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
data = await resp.read()
except aiohttp.ClientError as err:
return {"success": False, "error": f"Failed to download media: {err}"}
if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": f"Media size exceeds limit"}
filename = url.split("/")[-1].split("?")[0] or "file"
return await self._send_document(
chat_id, data, filename, caption, reply_to_message_id,
parse_mode, url, assets[0].get("content_type"),
assets[0].get("cache_key"),
)
return await self._send_media_group(
chat_id, assets, caption, reply_to_message_id, max_group_size,
@@ -211,133 +334,85 @@ class TelegramClient:
max_asset_data_size: int | None = None, send_large_photos_as_documents: bool = False,
content_type: str | None = None, cache_key: str | None = None,
download_headers: dict[str, str] | None = None,
preloaded_data: bytes | None = None,
) -> NotificationResult:
if not content_type:
content_type = "image/jpeg"
if not url:
return {"success": False, "error": "Missing 'url' for photo"}
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(url, cache_key)
# Check cache
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
cache, key, thumbhash = self._get_cache_and_key(url, cache_key)
cached = cache.get(key, thumbhash=thumbhash) if cache and key else None
if cached and cached.get("file_id"):
payload = {"chat_id": chat_id, "photo": cached["file_id"], "parse_mode": parse_mode}
if caption:
payload["caption"] = caption
if reply_to_message_id:
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendPhoto"
try:
async with self._session.post(telegram_url, json=payload) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
return {"success": True, "message_id": result.get("result", {}).get("message_id"), "cached": True}
except aiohttp.ClientError:
pass
cached_result = await self._send_from_cache(
_PHOTO_KIND, chat_id, cached["file_id"],
caption, reply_to_message_id, parse_mode,
)
if cached_result is not None:
return cached_result
try:
download_url = self._resolve_url(url)
async with self._session.get(download_url, headers=download_headers or {}) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download photo: HTTP {resp.status}"}
data = await resp.read()
data, err = await self._fetch_bytes(url, download_headers, preloaded_data)
if data is None:
return {"success": False, "error": f"Failed to download photo: {err}"}
if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": "Photo exceeds size limit", "skipped": True}
if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": "Photo exceeds size limit", "skipped": True}
exceeds_limits, reason, _, _ = check_photo_limits(data)
if exceeds_limits:
if send_large_photos_as_documents:
return await self._send_document(chat_id, data, "photo.jpg", caption, reply_to_message_id, parse_mode, url, None, cache_key)
return {"success": False, "error": f"Photo {reason}", "skipped": True}
exceeds_limits, reason, _, _ = check_photo_limits(data)
if exceeds_limits:
if send_large_photos_as_documents:
return await self._send_document(
chat_id, data, "photo.jpg", caption, reply_to_message_id,
parse_mode, url, None, cache_key,
)
return {"success": False, "error": f"Photo {reason}", "skipped": True}
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field("photo", data, filename="photo.jpg", content_type=content_type)
form.add_field("parse_mode", parse_mode)
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendPhoto"
async with self._session.post(telegram_url, data=form) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
photos = result.get("result", {}).get("photo", [])
if photos and effective_cache and effective_cache_key:
file_id = photos[-1].get("file_id")
if file_id:
await effective_cache.async_set(effective_cache_key, file_id, "photo", thumbhash=effective_thumbhash)
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
return await self._upload_media(
_PHOTO_KIND, chat_id, data,
_PHOTO_KIND.default_filename,
content_type or _PHOTO_KIND.default_content_type,
caption, reply_to_message_id, parse_mode,
cache, key, thumbhash,
)
async def _send_video(
self, chat_id: str, url: str | None, caption: str | None = None,
reply_to_message_id: int | None = None, parse_mode: str = "HTML",
max_asset_data_size: int | None = None, content_type: str | None = None,
cache_key: str | None = None, download_headers: dict[str, str] | None = None,
preloaded_data: bytes | None = None,
) -> NotificationResult:
if not content_type:
content_type = "video/mp4"
if not url:
return {"success": False, "error": "Missing 'url' for video"}
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(url, cache_key)
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
cache, key, thumbhash = self._get_cache_and_key(url, cache_key)
cached = cache.get(key, thumbhash=thumbhash) if cache and key else None
if cached and cached.get("file_id"):
payload = {"chat_id": chat_id, "video": cached["file_id"], "parse_mode": parse_mode}
if caption:
payload["caption"] = caption
if reply_to_message_id:
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendVideo"
try:
async with self._session.post(telegram_url, json=payload) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
return {"success": True, "message_id": result.get("result", {}).get("message_id"), "cached": True}
except aiohttp.ClientError:
pass
cached_result = await self._send_from_cache(
_VIDEO_KIND, chat_id, cached["file_id"],
caption, reply_to_message_id, parse_mode,
)
if cached_result is not None:
return cached_result
try:
download_url = self._resolve_url(url)
async with self._session.get(download_url, headers=download_headers or {}) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download video: HTTP {resp.status}"}
data = await resp.read()
data, err = await self._fetch_bytes(url, download_headers, preloaded_data)
if data is None:
return {"success": False, "error": f"Failed to download video: {err}"}
if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": "Video exceeds size limit", "skipped": True}
if len(data) > TELEGRAM_MAX_VIDEO_SIZE:
return {"success": False, "error": f"Video exceeds Telegram's {TELEGRAM_MAX_VIDEO_SIZE // (1024*1024)} MB limit", "skipped": True}
if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": "Video exceeds size limit", "skipped": True}
if len(data) > TELEGRAM_MAX_VIDEO_SIZE:
return {
"success": False,
"error": f"Video exceeds Telegram's {TELEGRAM_MAX_VIDEO_SIZE // (1024*1024)} MB limit",
"skipped": True,
}
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field("video", data, filename="video.mp4", content_type=content_type)
form.add_field("parse_mode", parse_mode)
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendVideo"
async with self._session.post(telegram_url, data=form) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
video = result.get("result", {}).get("video", {})
if video and effective_cache and effective_cache_key:
file_id = video.get("file_id")
if file_id:
await effective_cache.async_set(effective_cache_key, file_id, "video", thumbhash=effective_thumbhash)
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
return await self._upload_media(
_VIDEO_KIND, chat_id, data,
_VIDEO_KIND.default_filename,
content_type or _VIDEO_KIND.default_content_type,
caption, reply_to_message_id, parse_mode,
cache, key, thumbhash,
)
async def _send_document(
self, chat_id: str, data: bytes, filename: str = "file",
@@ -348,50 +423,24 @@ class TelegramClient:
if not content_type:
content_type, _ = mimetypes.guess_type(filename)
if not content_type:
content_type = "application/octet-stream"
content_type = _DOCUMENT_KIND.default_content_type
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(source_url, cache_key)
cache, key, thumbhash = self._get_cache_and_key(source_url, cache_key)
if cache and key:
cached = cache.get(key, thumbhash=thumbhash)
if cached and cached.get("file_id") and cached.get("type") == _DOCUMENT_KIND.cache_type:
cached_result = await self._send_from_cache(
_DOCUMENT_KIND, chat_id, cached["file_id"],
caption, reply_to_message_id, parse_mode,
)
if cached_result is not None:
return cached_result
if effective_cache and effective_cache_key:
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash)
if cached and cached.get("file_id") and cached.get("type") == "document":
payload = {"chat_id": chat_id, "document": cached["file_id"], "parse_mode": parse_mode}
if caption:
payload["caption"] = caption
if reply_to_message_id:
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendDocument"
try:
async with self._session.post(telegram_url, json=payload) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
return {"success": True, "message_id": result.get("result", {}).get("message_id"), "cached": True}
except aiohttp.ClientError:
pass
try:
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field("document", data, filename=filename, content_type=content_type)
form.add_field("parse_mode", parse_mode)
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendDocument"
async with self._session.post(telegram_url, data=form) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
if effective_cache_key and effective_cache:
document = result.get("result", {}).get("document", {})
file_id = document.get("file_id")
if file_id:
await effective_cache.async_set(effective_cache_key, file_id, "document", thumbhash=effective_thumbhash)
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
return await self._upload_media(
_DOCUMENT_KIND, chat_id, data, filename, content_type,
caption, reply_to_message_id, parse_mode,
cache, key, thumbhash,
)
async def _send_media_group(
self, chat_id: str, assets: list[dict[str, str]],
@@ -411,9 +460,9 @@ class TelegramClient:
chunk_caption = caption if chunk_idx == 0 else None
chunk_reply = reply_to_message_id if chunk_idx == 0 else None
if item.get("type") == "photo":
result = await self._send_photo(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, send_large_photos_as_documents, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"))
result = await self._send_photo(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, send_large_photos_as_documents, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"), preloaded_data=item.get("data"))
elif item.get("type") == "video":
result = await self._send_video(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"))
result = await self._send_video(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"), preloaded_data=item.get("data"))
else:
continue
if not result.get("success"):
@@ -433,7 +482,8 @@ class TelegramClient:
# Track cache info per media_json entry (in order) so we can map
# Telegram response items back to cache keys for newly uploaded items.
# None = already cached (no need to store), tuple = needs caching.
media_cache_info: list[tuple[str, str, str | None] | None] = []
# Tuple is (cache_key, media_type, thumbhash, uploaded_size).
media_cache_info: list[tuple[str, str, str | None, int] | None] = []
# Resolve cache hits and collect download tasks in parallel
async def _fetch_asset(idx: int, item: dict) -> tuple[int, dict | None, bytes | None]:
@@ -454,6 +504,20 @@ class TelegramClient:
if cached and cached.get("file_id"):
return idx, cached, None
# Use preloaded bytes if the dispatcher already fetched them
preloaded = item.get("data")
if preloaded is not None:
data = preloaded
if max_asset_data_size and len(data) > max_asset_data_size:
return idx, None, None
if media_type == "video" and len(data) > TELEGRAM_MAX_VIDEO_SIZE:
return idx, None, None
if media_type == "photo":
exceeds, _, _, _ = check_photo_limits(data)
if exceeds:
return idx, None, None
return idx, None, data
try:
download_url = self._resolve_url(url)
dl_headers = item.get("headers") or {}
@@ -500,7 +564,7 @@ class TelegramClient:
ck_is_asset = is_asset_cache_key(ck)
bare_ck = asset_id_from_cache_key(ck) if ck_is_asset else ck
th = self._thumbhash_resolver(bare_ck) if ck_is_asset and self._thumbhash_resolver else None
media_cache_info.append((ck, media_type, th))
media_cache_info.append((ck, media_type, th, len(data)))
else:
continue
@@ -523,14 +587,14 @@ class TelegramClient:
all_message_ids.extend(msg.get("message_id") for msg in result_msgs)
# Cache file_ids from response — map by position
cache_entries: list[tuple[str, str, str, str | None]] = []
cache_entries: list[tuple[str, str, str, str | None, int | None]] = []
for i, msg in enumerate(result_msgs):
if i >= len(media_cache_info):
break
info = media_cache_info[i]
if info is None:
continue # was a cache hit, skip
ck, mt, th = info
ck, mt, th, sz = info
file_id = None
if msg.get("photo"):
file_id = msg["photo"][-1].get("file_id")
@@ -539,7 +603,7 @@ class TelegramClient:
elif msg.get("document"):
file_id = msg["document"].get("file_id")
if file_id:
cache_entries.append((ck, file_id, mt, th))
cache_entries.append((ck, file_id, mt, th, sz))
if cache_entries:
# All entries in a chunk share the same cache backend
eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0]))
@@ -568,6 +632,18 @@ class TelegramClient:
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def get_chat(self, chat_id: str) -> dict[str, Any]:
"""Call getChat to fetch up-to-date chat metadata (title, username, type, etc.)."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getChat"
try:
async with self._session.post(url, json={"chat_id": chat_id}) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True, "result": data.get("result", {})}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def get_webhook_info(self) -> dict[str, Any]:
"""Call getWebhookInfo to check current webhook status."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getWebhookInfo"
@@ -132,8 +132,10 @@ class ImmichActionExecutor(ActionExecutor):
target_album_ids = [single]
try:
# Step 1: Gather candidate assets from criteria
candidate_ids = await self._gather_candidates(criteria)
# Step 1: Gather candidate assets from criteria. Asset type is
# kept alongside the id so we can pick the first *photo* (not a
# video) as an album thumbnail when one is missing.
candidate_ids, types_by_id = await self._gather_candidates(criteria)
if not candidate_ids:
return RuleResult(
@@ -146,6 +148,7 @@ class ImmichActionExecutor(ActionExecutor):
)
# If no target albums and create_if_missing, create one
album_created_now: set[str] = set()
if not target_album_ids and create_if_missing and create_album_name:
if dry_run:
_LOGGER.info("[DRY RUN] Would create album '%s'", create_album_name)
@@ -153,6 +156,8 @@ class ImmichActionExecutor(ActionExecutor):
else:
created = await self._client.create_album(create_album_name)
target_album_ids = [created.get("id", "")]
if target_album_ids[0]:
album_created_now.add(target_album_ids[0])
_LOGGER.info("Created album '%s' with id %s", create_album_name, target_album_ids[0])
if not target_album_ids:
@@ -169,6 +174,7 @@ class ImmichActionExecutor(ActionExecutor):
for album_id in target_album_ids:
album_asset_ids: set[str] = set()
needs_thumbnail = album_id in album_created_now
if album_id and album_id != "__dry_run_new__":
album = await self._client.get_album(album_id)
@@ -176,27 +182,56 @@ class ImmichActionExecutor(ActionExecutor):
if not dry_run:
created = await self._client.create_album(create_album_name)
album_id = created.get("id", album_id)
album_created_now.add(album_id)
needs_thumbnail = True
_LOGGER.info("Created album '%s' with id %s", create_album_name, album_id)
elif album is None:
album_details.append({"album_id": album_id, "error": "not found"})
continue
elif album is not None:
album_asset_ids = set(album.asset_ids)
if not album.thumbnail_asset_id:
needs_thumbnail = True
new_asset_ids = [aid for aid in candidate_ids if aid not in album_asset_ids]
skipped = len(candidate_ids) - len(new_asset_ids)
thumbnail_set_id: str | None = None
if new_asset_ids and not dry_run and album_id:
for i in range(0, len(new_asset_ids), 500):
batch = new_asset_ids[i : i + 500]
await self._client.add_assets_to_album(album_id, batch)
_LOGGER.info("Added %d assets to album %s", len(new_asset_ids), album_id)
# Best-effort: give newly-created/empty-thumbnail albums a
# cover. Prefer the first image; fall back to the first
# added asset of any type if none are images (Immich renders
# a video poster, which still looks fine). Failures here
# must not fail the rule — the add already succeeded.
if needs_thumbnail:
pick = next(
(aid for aid in new_asset_ids if (types_by_id.get(aid) or "").lower() == "image"),
None,
) or new_asset_ids[0]
try:
await self._client.set_album_thumbnail(album_id, pick)
thumbnail_set_id = pick
_LOGGER.info("Set thumbnail of album %s to %s", album_id, pick)
except ImmichApiError as err:
_LOGGER.warning(
"Could not set thumbnail for album %s: %s", album_id, err
)
elif dry_run and new_asset_ids:
_LOGGER.info("[DRY RUN] Would add %d assets to album %s", len(new_asset_ids), album_id)
if needs_thumbnail:
_LOGGER.info("[DRY RUN] Would set album %s thumbnail to first added asset", album_id)
total_affected += len(new_asset_ids)
total_skipped += skipped
album_details.append({"album_id": album_id, "added": len(new_asset_ids), "skipped": skipped})
detail = {"album_id": album_id, "added": len(new_asset_ids), "skipped": skipped}
if thumbnail_set_id:
detail["thumbnail_set_to"] = thumbnail_set_id
album_details.append(detail)
return RuleResult(
rule_name=rule_name,
@@ -228,10 +263,16 @@ class ImmichActionExecutor(ActionExecutor):
async def _gather_candidates(
self, criteria: dict[str, Any]
) -> list[str]:
"""Gather asset IDs matching the criteria (union of all sources)."""
) -> tuple[list[str], dict[str, str]]:
"""Gather asset IDs matching the criteria (union of all sources).
Returns ``(ordered_ids, types_by_id)`` so callers that need asset
type e.g. picking a photo for an album thumbnail don't have to
re-fetch each asset.
"""
seen: set[str] = set()
result: list[str] = []
types_by_id: dict[str, str] = {}
# Source 1: Person assets
person_ids = criteria.get("person_ids", [])
@@ -243,6 +284,7 @@ class ImmichActionExecutor(ActionExecutor):
if self._matches_filters(asset, criteria):
seen.add(aid)
result.append(aid)
types_by_id[aid] = asset.get("type", "") or ""
# Source 2: Smart search
query = criteria.get("query", "")
@@ -254,6 +296,7 @@ class ImmichActionExecutor(ActionExecutor):
if self._matches_filters(asset, criteria):
seen.add(aid)
result.append(aid)
types_by_id[aid] = asset.get("type", "") or ""
# Exclude assets belonging to excluded persons
exclude_person_ids = criteria.get("exclude_person_ids", [])
@@ -266,8 +309,12 @@ class ImmichActionExecutor(ActionExecutor):
if aid:
excluded_asset_ids.add(aid)
result = [aid for aid in result if aid not in excluded_asset_ids]
for aid in list(types_by_id):
if aid not in excluded_asset_ids:
continue
types_by_id.pop(aid, None)
return result
return result, types_by_id
def _matches_filters(
self, asset: dict[str, Any], criteria: dict[str, Any]
@@ -243,6 +243,16 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
except (ValueError, AttributeError):
created_at = datetime.now(timezone.utc)
# preview_url is what the notification dispatcher feeds to Telegram as the
# actual media bytes — for videos it must be the transcoded playback (mp4),
# not the JPEG thumbnail, or Telegram receives a JPEG labeled as video/mp4.
if asset.type == ASSET_TYPE_VIDEO:
preview_url = f"{external_url}/api/assets/{asset.id}/video/playback"
full_url = f"{external_url}/api/assets/{asset.id}/original"
else:
preview_url = f"{external_url}/api/assets/{asset.id}/thumbnail?size=preview"
full_url = f"{external_url}/api/assets/{asset.id}/original"
return MediaAsset(
id=asset.id,
type=media_type,
@@ -252,8 +262,8 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
description=asset.description or None,
tags=list(asset.people),
thumbnail_url=f"{external_url}/api/assets/{asset.id}/thumbnail",
preview_url=f"{external_url}/api/assets/{asset.id}/thumbnail?size=preview",
full_url=f"{external_url}/api/assets/{asset.id}/original",
preview_url=preview_url,
full_url=full_url,
extra={
"owner_id": asset.owner_id,
"is_favorite": asset.is_favorite,
@@ -264,7 +274,11 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
"state": asset.state,
"country": asset.country,
"thumbhash": asset.thumbhash,
# file_size = original asset bytes (from exifInfo.fileSizeInByte).
# playback_size = bytes we will actually upload (videos: transcoded
# /video/playback). Populated lazily at dispatch time via HEAD.
"file_size": asset.file_size,
"playback_size": None,
},
)
@@ -235,8 +235,9 @@ class ImmichClient:
query: str,
album_ids: list[str] | None = None,
limit: int = 10,
page: int = 1,
) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"query": query, "page": 1, "size": limit}
payload: dict[str, Any] = {"query": query, "page": max(1, page), "size": limit}
if album_ids:
payload["albumIds"] = album_ids
try:
@@ -258,8 +259,9 @@ class ImmichClient:
query: str,
album_ids: list[str] | None = None,
limit: int = 10,
page: int = 1,
) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"originalFileName": query, "page": 1, "size": limit}
payload: dict[str, Any] = {"originalFileName": query, "page": max(1, page), "size": limit}
if album_ids:
payload["albumIds"] = album_ids
try:
@@ -279,14 +281,28 @@ class ImmichClient:
async def search_by_person(
self, person_id: str, limit: int = 10
) -> list[dict[str, Any]]:
"""Fetch up to ``limit`` assets tagged with ``person_id``.
Uses ``POST /api/search/metadata`` with ``personIds`` the public
``GET /api/people/{id}/assets`` endpoint was removed from Immich
around v1.106 and now silently 404s, which is why this method used
to return an empty list on current servers.
"""
payload: dict[str, Any] = {
"personIds": [person_id],
"page": 1,
"size": max(1, min(limit, 100)),
}
try:
async with self._session.get(
f"{self._url}/api/people/{person_id}/assets",
headers=self._headers,
async with self._session.post(
f"{self._url}/api/search/metadata",
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
data = await response.json()
return data[:limit] if isinstance(data, list) else []
items = data.get("assets", {}).get("items", [])
return items[:limit]
except aiohttp.ClientError:
pass
return []
@@ -329,7 +345,15 @@ class ImmichClient:
async def add_assets_to_album(
self, album_id: str, asset_ids: list[str]
) -> dict[str, Any]:
"""Add assets to an album. Returns API response with success/error arrays."""
"""Add assets to an album. Returns API response with success/error arrays.
Immich returns 200 with a per-asset array even when some IDs fail
individually (already in album, not found, etc). Partial failures
are data, not errors surface them as the normal return value.
Non-2xx responses include Immich's error body in the raised message
so callers and logs see the real reason (bad UUIDs, stale album,
permission, etc.) instead of just the HTTP status code.
"""
payload = {"ids": asset_ids}
try:
async with self._session.put(
@@ -337,14 +361,51 @@ class ImmichClient:
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
return await response.json()
body_text = await response.text()
if response.status in (200, 201):
try:
parsed = await response.json(content_type=None)
except Exception: # noqa: BLE001 — malformed body, still 200
return {"raw": body_text}
# Per-asset array is the typical shape; wrap for consistency.
if isinstance(parsed, list):
return {"results": parsed}
if isinstance(parsed, dict):
return parsed
return {"raw": body_text}
raise ImmichApiError(
f"Failed to add assets to album {album_id}: HTTP {response.status}"
f"Failed to add assets to album {album_id}: "
f"HTTP {response.status} body={body_text[:512]}"
)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error adding assets to album: {err}") from err
async def set_album_thumbnail(
self, album_id: str, asset_id: str
) -> None:
"""Set an album's cover/thumbnail to the given asset.
Uses ``PATCH /api/albums/{id}`` with ``albumThumbnailAssetId``.
Raises ``ImmichApiError`` on non-2xx so callers can treat it as
best-effort and log.
"""
payload = {"albumThumbnailAssetId": asset_id}
try:
async with self._session.patch(
f"{self._url}/api/albums/{album_id}",
headers=self._json_headers,
json=payload,
) as response:
if response.status in (200, 201, 204):
return
body_text = await response.text()
raise ImmichApiError(
f"Failed to set album thumbnail for {album_id}: "
f"HTTP {response.status} body={body_text[:512]}"
)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error setting album thumbnail: {err}") from err
async def remove_assets_from_album(
self, album_id: str, asset_ids: list[str]
) -> dict[str, Any]:
@@ -386,22 +447,49 @@ class ImmichClient:
raise ImmichApiError(f"Error creating album: {err}") from err
async def get_person_assets_all(self, person_id: str) -> list[dict[str, Any]]:
"""Fetch ALL assets for a person (no limit)."""
try:
async with self._session.get(
f"{self._url}/api/people/{person_id}/assets",
headers=self._headers,
) as response:
if response.status == 200:
data = await response.json()
return data if isinstance(data, list) else []
if response.status == 404:
return []
raise ImmichApiError(
f"Failed to fetch person {person_id} assets: HTTP {response.status}"
)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error fetching person assets: {err}") from err
"""Fetch ALL assets tagged with a person (paginated, no soft cap).
Uses ``POST /api/search/metadata`` with ``personIds``. The legacy
``GET /api/people/{id}/assets`` endpoint was removed from Immich
around v1.106 and returns 404 on current servers switching to
the search endpoint is the only way to get person-filtered assets
from modern Immich.
"""
all_items: list[dict[str, Any]] = []
page = 1
page_size = 100
max_pages = 1000 # hard cap to avoid runaway loops if server misbehaves
while page <= max_pages:
payload: dict[str, Any] = {
"personIds": [person_id],
"page": page,
"size": page_size,
}
try:
async with self._session.post(
f"{self._url}/api/search/metadata",
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
data = await response.json()
items = data.get("assets", {}).get("items", [])
if not items:
break
all_items.extend(items)
if len(items) < page_size:
break
page += 1
continue
if response.status == 404:
# Person doesn't exist — return empty rather than raising
return all_items
raise ImmichApiError(
f"Failed to fetch person {person_id} assets: HTTP {response.status}"
)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error fetching person assets: {err}") from err
return all_items
async def search_smart_all(
self, query: str, limit: int = 1000
@@ -64,6 +64,8 @@ def build_template_context(
# Flatten extras into asset dict for template access
asset_dict.update(asset.extra)
asset_dict.setdefault("oversized", False)
asset_dict.setdefault("file_size", None)
asset_dict.setdefault("playback_size", None)
assets.append(asset_dict)
# Enrich assets with per-asset public URLs if album has a public share link
@@ -87,12 +89,16 @@ def build_template_context(
ctx["max_video_size"] = max_video_bytes # bytes or None
ctx["max_video_size_mb"] = max_video_bytes // (1024 * 1024) if max_video_bytes else None
# Oversize check uses playback_size (bytes we actually upload). file_size
# (original asset size) is informational only — for providers that transcode
# before sending (e.g. Immich /video/playback), original can be much larger
# than what reaches Telegram, so it would false-positive the warning.
has_oversized = False
if max_video_bytes:
for a in assets:
if a.get("type") == "VIDEO":
fs = a.get("file_size")
oversized = fs is not None and fs > max_video_bytes
size = a.get("playback_size")
oversized = size is not None and size > max_video_bytes
a["oversized"] = oversized
if oversized:
has_oversized = True
@@ -1,10 +1,13 @@
"""Configuration backup/restore API (admin only)."""
import asyncio
import json
import logging
import os
import signal
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, File, Query
from fastapi.responses import JSONResponse
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -16,10 +19,24 @@ from ..services.backup_schema import (
ALL_CATEGORIES, BackupCategory, BackupFile, ConflictMode, SecretsMode,
)
from ..services.backup_service import (
cleanup_old_backups, export_backup, import_backup, list_backup_files,
validate_backup,
cleanup_old_backups, export_backup, export_backup_to_file, import_backup,
list_backup_files, validate_backup,
)
# Pending-restore marker keys (single source of truth consumed at startup)
PENDING_RESTORE_PATH_KEY = "pending_restore_path"
PENDING_RESTORE_CONFLICT_KEY = "pending_restore_conflict_mode"
PENDING_RESTORE_UPLOADED_AT_KEY = "pending_restore_uploaded_at"
PENDING_RESTORE_UPLOADED_BY_KEY = "pending_restore_uploaded_by"
def _pending_restore_path():
return app_config.data_dir / "pending_restore.json"
def _applied_restores_dir():
return app_config.data_dir / "applied_restores"
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/backup", tags=["backup"])
@@ -131,6 +148,188 @@ async def import_config(
return result.model_dump()
# ---------------------------------------------------------------------------
# Pending restore (prepare → apply on next restart)
# ---------------------------------------------------------------------------
async def _set_app_setting(session: AsyncSession, key: str, value: str) -> None:
row = await session.get(AppSetting, key)
if row:
row.value = value
else:
row = AppSetting(key=key, value=value)
session.add(row)
async def _clear_pending_restore_markers(session: AsyncSession) -> None:
for key in (
PENDING_RESTORE_PATH_KEY,
PENDING_RESTORE_CONFLICT_KEY,
PENDING_RESTORE_UPLOADED_AT_KEY,
PENDING_RESTORE_UPLOADED_BY_KEY,
):
row = await session.get(AppSetting, key)
if row:
await session.delete(row)
@router.post("/prepare-restore")
async def prepare_restore(
file: UploadFile = File(...),
conflict_mode: ConflictMode = Query(default=ConflictMode.SKIP),
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Stage a backup for restore on next backend restart.
Validates the uploaded file, writes it to ``data/pending_restore.json``,
and persists marker settings so startup will apply it atomically.
"""
content = await file.read()
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
try:
raw = json.loads(content)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
validation = validate_backup(raw)
if not validation.valid:
raise HTTPException(
status_code=400,
detail=f"Invalid backup: {'; '.join(validation.errors)}",
)
pending_path = _pending_restore_path()
pending_path.parent.mkdir(parents=True, exist_ok=True)
# Atomic write: write to tmp then rename, so a crash mid-write never
# leaves a truncated pending_restore.json that would break startup apply.
tmp_path = pending_path.with_suffix(pending_path.suffix + ".tmp")
tmp_path.write_text(json.dumps(raw), encoding="utf-8")
os.replace(tmp_path, pending_path)
now_iso = datetime.now(timezone.utc).isoformat()
await _set_app_setting(session, PENDING_RESTORE_PATH_KEY, str(pending_path))
await _set_app_setting(session, PENDING_RESTORE_CONFLICT_KEY, conflict_mode.value)
await _set_app_setting(session, PENDING_RESTORE_UPLOADED_AT_KEY, now_iso)
await _set_app_setting(session, PENDING_RESTORE_UPLOADED_BY_KEY, user.username)
await session.commit()
return {
"pending": True,
"uploaded_at": now_iso,
"uploaded_by": user.username,
"conflict_mode": conflict_mode.value,
"validation": validation.model_dump(),
"supervised": _is_supervised(),
}
@router.get("/pending-restore")
async def get_pending_restore(
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Return current pending-restore state, or null if none."""
path_row = await session.get(AppSetting, PENDING_RESTORE_PATH_KEY)
if not path_row or not path_row.value:
return {"pending": False, "supervised": _is_supervised()}
conflict_row = await session.get(AppSetting, PENDING_RESTORE_CONFLICT_KEY)
uploaded_at_row = await session.get(AppSetting, PENDING_RESTORE_UPLOADED_AT_KEY)
uploaded_by_row = await session.get(AppSetting, PENDING_RESTORE_UPLOADED_BY_KEY)
return {
"pending": True,
"uploaded_at": uploaded_at_row.value if uploaded_at_row else None,
"uploaded_by": uploaded_by_row.value if uploaded_by_row else None,
"conflict_mode": (conflict_row.value if conflict_row else ConflictMode.SKIP.value),
"supervised": _is_supervised(),
}
@router.delete("/pending-restore")
async def cancel_pending_restore(
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Cancel a pending restore."""
pending_path = _pending_restore_path()
if pending_path.exists():
pending_path.unlink()
await _clear_pending_restore_markers(session)
await session.commit()
return {"cancelled": True}
def _is_supervised() -> bool:
"""Heuristic: is this process managed by something that will respawn it?
Priority order:
1. Explicit operator override: ``NOTIFY_BRIDGE_SUPERVISED`` env var or
the ``supervised`` AppSetting (values: ``true``/``false``/``auto``).
``auto`` (or unset) falls through to the detection heuristic.
2. Heuristic: look at common container/service-manager env vars.
Used by the frontend to decide whether to offer "Restart now" a bad
guess here is a foot-gun (process exits, stays dead), so err on the side
of false when unsure.
"""
override = os.environ.get("NOTIFY_BRIDGE_SUPERVISED", "").strip().lower()
if override in ("true", "1", "yes", "on"):
return True
if override in ("false", "0", "no", "off"):
return False
for var in ("CONTAINER", "DOCKER_CONTAINER", "KUBERNETES_SERVICE_HOST",
"INVOCATION_ID", "PM2_HOME"):
if os.environ.get(var):
return True
if os.path.exists("/.dockerenv"):
return True
return False
@router.post("/apply-restart")
async def apply_and_restart(
background_tasks: BackgroundTasks,
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Trigger a graceful exit so the supervisor respawns and applies the pending restore.
Only allowed when a pending restore is staged AND the process is supervised.
"""
path_row = await session.get(AppSetting, PENDING_RESTORE_PATH_KEY)
if not path_row or not path_row.value:
raise HTTPException(status_code=409, detail="No pending restore to apply")
if not _is_supervised():
raise HTTPException(
status_code=409,
detail=(
"This process is not supervised. Restart the backend manually to apply "
"the pending restore, or use the Cancel button."
),
)
async def _shutdown_soon() -> None:
# Small delay so the HTTP response flushes before the signal fires.
await asyncio.sleep(0.5)
_LOGGER.warning("Admin triggered restart to apply pending restore")
# SIGTERM lets uvicorn run its normal graceful shutdown:
# drain in-flight requests, fire the lifespan shutdown hooks
# (close_http_session, scheduler.shutdown), then exit. The
# supervisor respawns, and startup applies the pending restore.
try:
os.kill(os.getpid(), signal.SIGTERM)
except Exception: # noqa: BLE001 — last-resort fallback on platforms that reject SIGTERM
_LOGGER.exception("SIGTERM delivery failed; falling back to os._exit")
os._exit(0)
background_tasks.add_task(_shutdown_soon)
return {"restart_requested": True}
# ---------------------------------------------------------------------------
# Scheduled backup settings
# ---------------------------------------------------------------------------
@@ -205,6 +404,37 @@ async def get_backup_files(
return list_backup_files(_backup_dir())
@router.post("/files")
async def create_manual_backup(
secrets_mode: SecretsMode = Query(default=SecretsMode.EXCLUDE),
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Create a backup file in the backups directory (manual checkpoint).
Produces the same JSON format as scheduled backups, saved under
``data/backups/backup-<timestamp>.json``. Retention is managed by the
existing scheduled-backup settings (``backup_retention_count``).
"""
backup_dir = _backup_dir()
filepath = await export_backup_to_file(session, user.id, backup_dir, secrets_mode)
# Apply the same retention as scheduled backups if configured.
retention_row = await session.get(AppSetting, "backup_retention_count")
if retention_row and retention_row.value:
try:
retention = int(retention_row.value)
if retention > 0:
cleanup_old_backups(backup_dir, keep=retention)
except ValueError:
pass
stat = filepath.stat()
return {
"filename": filepath.name,
"size": stat.st_size,
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
}
@router.get("/files/{filename}")
async def download_backup_file(
filename: str,
@@ -42,6 +42,11 @@ class CommandTrackerUpdate(BaseModel):
class ListenerCreate(BaseModel):
listener_type: str
listener_id: int
allowed_album_ids: list[str] | None = None
class ListenerUpdate(BaseModel):
allowed_album_ids: list[str] | None = None
# --- Command Tracker CRUD ---
@@ -299,6 +304,7 @@ async def add_listener(
command_tracker_id=tracker_id,
listener_type=body.listener_type,
listener_id=body.listener_id,
allowed_album_ids=body.allowed_album_ids,
)
session.add(listener)
await session.commit()
@@ -316,6 +322,30 @@ async def add_listener(
return await _listener_response(session, listener)
@router.patch("/{tracker_id}/listeners/{listener_id}")
async def update_listener(
tracker_id: int,
listener_id: int,
body: ListenerUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a listener's per-chat settings (currently just allowed_album_ids)."""
await _get_user_tracker(session, tracker_id, user.id)
listener = await session.get(CommandTrackerListener, listener_id)
if not listener or listener.command_tracker_id != tracker_id:
raise HTTPException(status_code=404, detail="Listener not found")
# Empty list means "no albums" which is rarely useful; treat as null (inherit).
if body.allowed_album_ids is not None and len(body.allowed_album_ids) == 0:
listener.allowed_album_ids = None
else:
listener.allowed_album_ids = body.allowed_album_ids
session.add(listener)
await session.commit()
await session.refresh(listener)
return await _listener_response(session, listener)
@router.delete("/{tracker_id}/listeners/{listener_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_listener(
tracker_id: int,
@@ -394,6 +424,7 @@ async def _listener_response(session: AsyncSession, l: CommandTrackerListener) -
"command_tracker_id": l.command_tracker_id,
"listener_type": l.listener_type,
"listener_id": l.listener_id,
"allowed_album_ids": l.allowed_album_ids,
"name": name,
"created_at": l.created_at.isoformat(),
}
@@ -19,10 +19,22 @@ from ..database.models import (
def raise_if_used(consumers: list[str], entity_name: str) -> None:
"""Raise 409 Conflict if the entity has consumers."""
"""Raise 409 Conflict if the entity has consumers.
Produces a human-readable summary string (kept as the primary ``detail``)
plus a structured ``blocked_by`` list so the frontend can render a
clickable warning modal.
"""
if consumers:
detail = f"Cannot delete {entity_name}: used by {len(consumers)} consumer(s). " + "; ".join(consumers)
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
summary = f"Cannot delete {entity_name}: used by {len(consumers)} consumer(s)."
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": summary,
"entity": entity_name,
"blocked_by": consumers,
},
)
async def check_service_provider(session: AsyncSession, provider_id: int) -> list[str]:
@@ -3,12 +3,14 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import delete as sa_delete
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import (
Action,
CommandConfig,
CommandTemplateConfig,
CommandTracker,
@@ -54,12 +56,10 @@ async def get_status(
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
)).one()
# Build events query with filters
events_query = (
select(EventLog)
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id)
.where(NotificationTracker.user_id == user.id)
)
# Build events query with filters. EventLog.user_id is the owner column;
# action events (event_type starts with "action_") have tracker_id NULL but
# user_id set, so we filter by user_id directly.
events_query = select(EventLog).where(EventLog.user_id == user.id)
if event_type:
events_query = events_query.where(EventLog.event_type == event_type)
@@ -69,6 +69,7 @@ async def get_status(
events_query = events_query.where(
EventLog.collection_name.contains(search)
| EventLog.tracker_name.contains(search)
| EventLog.action_name.contains(search)
| EventLog.provider_name.contains(search)
)
@@ -84,6 +85,65 @@ async def get_status(
events_query = events_query.offset(offset).limit(limit)
recent_events = await session.exec(events_query)
event_rows = recent_events.all()
# Resolve live tracker names from FK (fall back to stored snapshot when deleted)
tracker_ids = {e.tracker_id for e in event_rows if e.tracker_id is not None}
tracker_name_map: dict[int, str] = {}
if tracker_ids:
tracker_rows = (await session.exec(
select(NotificationTracker.id, NotificationTracker.name).where(
NotificationTracker.id.in_(tracker_ids)
)
)).all()
tracker_name_map = {tid: tname for tid, tname in tracker_rows}
# Resolve live provider names similarly
provider_ids = {e.provider_id for e in event_rows if e.provider_id is not None}
provider_name_map: dict[int, str] = {}
if provider_ids:
provider_rows = (await session.exec(
select(ServiceProvider.id, ServiceProvider.name).where(
ServiceProvider.id.in_(provider_ids)
)
)).all()
provider_name_map = {pid: pname for pid, pname in provider_rows}
# Resolve live action names so renames are reflected; fall back to snapshot.
action_ids = {e.action_id for e in event_rows if e.action_id is not None}
action_name_map: dict[int, str] = {}
if action_ids:
action_rows = (await session.exec(
select(Action.id, Action.name).where(Action.id.in_(action_ids))
)).all()
action_name_map = {aid: aname for aid, aname in action_rows}
def _display_tracker_name(e: EventLog) -> str:
if e.tracker_id is not None and e.tracker_id in tracker_name_map:
return tracker_name_map[e.tracker_id]
return f"(deleted) {e.tracker_name}" if e.tracker_name else "(deleted)"
def _display_provider_name(e: EventLog) -> str:
if e.provider_id is not None and e.provider_id in provider_name_map:
return provider_name_map[e.provider_id]
return e.provider_name or ""
def _display_action_name(e: EventLog) -> str:
if e.action_id is not None and e.action_id in action_name_map:
return action_name_map[e.action_id]
if e.action_name:
return f"(deleted) {e.action_name}"
return ""
def _display_subject(e: EventLog) -> str:
"""The primary label shown on the event row.
For action events the ``collection_name`` stores the action name;
use the live-resolved action name when available so renames show.
"""
if e.action_id is not None or (e.event_type or "").startswith("action_"):
return _display_action_name(e) or e.collection_name
return e.collection_name
return {
"providers": providers_count,
@@ -94,19 +154,43 @@ async def get_status(
{
"id": e.id,
"event_type": e.event_type,
"collection_name": e.collection_name,
"tracker_name": e.tracker_name or "",
"provider_name": e.provider_name or "",
"collection_name": _display_subject(e),
"tracker_name": _display_tracker_name(e),
"action_id": e.action_id,
"action_name": _display_action_name(e),
"provider_name": _display_provider_name(e),
"provider_id": e.provider_id,
"assets_count": e.assets_count or 0,
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
"details": e.details or {},
}
for e in recent_events.all()
for e in event_rows
],
}
@router.delete("/events")
async def clear_events(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
older_than_days: int | None = Query(None, ge=0),
):
"""Delete all event log entries for the current user.
Optionally keep events newer than `older_than_days` days.
"""
stmt = sa_delete(EventLog).where(EventLog.user_id == user.id)
if older_than_days is not None:
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
stmt = stmt.where(EventLog.created_at < cutoff)
# Use session.execute() for DELETE (consistent with other endpoints and
# avoids sqlmodel wrapping a CursorResult that may drop rowcount).
result = await session.execute(stmt)
await session.commit()
return {"deleted": result.rowcount or 0}
@router.get("/counts")
async def get_nav_counts(
user: User = Depends(get_current_user),
@@ -192,8 +276,7 @@ async def get_event_chart(
EventLog.event_type,
func.count().label("total"),
)
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id)
.where(NotificationTracker.user_id == user.id, EventLog.created_at >= cutoff)
.where(EventLog.user_id == user.id, EventLog.created_at >= cutoff)
)
if event_type:
@@ -204,6 +287,7 @@ async def get_event_chart(
query = query.where(
EventLog.collection_name.contains(search)
| EventLog.tracker_name.contains(search)
| EventLog.action_name.contains(search)
| EventLog.provider_name.contains(search)
)
@@ -162,8 +162,9 @@ async def get_template_variables(
"city": "City name",
"state": "State/region name",
"country": "Country name",
"file_size": "File size in bytes (null if unknown)",
"oversized": "Whether video exceeds the target's size limit (boolean, videos only)",
"file_size": "Original asset size in bytes (null if unknown)",
"playback_size": "Size in bytes of the media we actually upload — for Immich videos this is the transcoded /video/playback (null for photos or when unknown)",
"oversized": "Whether the asset's playback_size exceeds the target's size limit (boolean, videos only)",
"public_url": "Per-asset public share URL (empty if no album link)",
"url": "Public viewer URL (if shared)",
"download_url": "Direct download URL (if shared)",
@@ -69,6 +69,45 @@ async def create_user(
return {"id": user.id, "username": user.username, "role": user.role}
@router.patch("/{user_id}")
async def update_user(
user_id: int,
body: UserUpdate,
admin: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Update username and/or role for a user (admin only)."""
user = await session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if body.username is not None and body.username != user.username:
new_username = body.username.strip()
if not new_username:
raise HTTPException(status_code=400, detail="Username cannot be empty")
dup = await session.exec(select(User).where(User.username == new_username))
if dup.first():
raise HTTPException(status_code=409, detail="Username already exists")
user.username = new_username
if body.role is not None and body.role != user.role:
if body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="Invalid role")
# Prevent demoting the last admin
if user.role == "admin" and body.role != "admin":
admins = (await session.exec(
select(User).where(User.role == "admin")
)).all()
if len(admins) <= 1:
raise HTTPException(status_code=400, detail="Cannot demote the last admin")
user.role = body.role
session.add(user)
await session.commit()
await session.refresh(user)
return {"id": user.id, "username": user.username, "role": user.role}
class ResetPasswordRequest(BaseModel):
new_password: str
@@ -147,6 +147,7 @@ async def _dispatch_webhook_event(
# Log event
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
session.add(EventLog(
user_id=tracker.user_id,
tracker_id=tracker.id,
tracker_name=tracker.name,
provider_id=provider_id,
@@ -6,7 +6,10 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
from ..database.models import CommandConfig, CommandTracker, ServiceProvider, TelegramBot
from ..database.models import (
CommandConfig, CommandTracker, CommandTrackerListener,
ServiceProvider, TelegramBot,
)
@dataclass(frozen=True)
@@ -51,6 +54,9 @@ class ProviderCommandHandler(ABC):
bot: TelegramBot,
tracker: CommandTracker,
config: CommandConfig,
*,
listener: CommandTrackerListener | None = None,
page: int = 1,
) -> CommandResponse | None:
"""Handle a provider-specific command for a single tracker.
@@ -77,6 +77,9 @@ class GiteaCommandHandler(ProviderCommandHandler):
bot: TelegramBot,
tracker: CommandTracker,
config: CommandConfig,
*,
listener: Any = None,
page: int = 1,
) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd)
if fn is None:
@@ -95,7 +95,7 @@ def _render_cmd_template(
async def _resolve_command_context(
bot: TelegramBot,
) -> tuple[
list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
list[tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]],
dict[int, dict[str, dict[str, str]]],
]:
"""Resolve all enabled command trackers, configs, and providers for a bot.
@@ -148,7 +148,7 @@ async def _resolve_command_context(
else:
providers_by_id = {}
tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]] = []
tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]] = []
for listener in listeners:
tracker = trackers_by_id.get(listener.command_tracker_id)
if not tracker or not tracker.enabled:
@@ -159,12 +159,12 @@ async def _resolve_command_context(
provider = providers_by_id.get(tracker.provider_id)
if not provider:
continue
tuples.append((tracker, config, provider))
tuples.append((tracker, config, provider, listener))
# Load command template slots per config (not merged)
templates_by_config_id: dict[int, dict[str, dict[str, str]]] = {}
seen_config_ids: set[int] = set()
for _, config, _ in tuples:
for _, config, _, _ in tuples:
cfg_id = config.command_template_config_id
if cfg_id and cfg_id not in seen_config_ids:
seen_config_ids.add(cfg_id)
@@ -204,7 +204,7 @@ def _merge_all_templates(
def _merge_enabled_commands(
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]],
) -> tuple[list[str], dict[str, Any]]:
"""Merge enabled_commands (union) and rate_limits from all configs.
@@ -215,7 +215,7 @@ def _merge_enabled_commands(
enabled: set[str] = set()
merged_limits: dict[str, int] = {}
for _, config, _ in ctx:
for _, config, _, _ in ctx:
enabled.update(config.enabled_commands or [])
for category, cooldown in (config.rate_limits or {}).items():
if category not in merged_limits:
@@ -278,8 +278,16 @@ async def handle_command(
# Provider-specific dispatch — per-tracker
from .dispatch import get_handler
# For paginated commands (/search, /find) a trailing integer means page,
# not count. Preserve count_override meaning for all other commands.
paginated_cmds = {"search", "find"}
page = 1
if cmd in paginated_cmds and count_override:
page = max(1, count_override)
count_override = None
responses: list[CommandResponse] = []
for tracker, config, provider in ctx_tuples:
for tracker, config, provider, listener in ctx_tuples:
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
_LOGGER.warning(
"Truncated command responses at %d for bot %d cmd /%s",
@@ -298,6 +306,7 @@ async def handle_command(
result = await handler.handle(
cmd, args, count, locale, response_mode,
provider, tracker_templates, bot, tracker, config,
listener=listener, page=page,
)
if result is not None:
responses.append(result)
@@ -6,7 +6,7 @@ import logging
from typing import Any
from ...database.models import (
CommandConfig, CommandTracker,
CommandConfig, CommandTracker, CommandTrackerListener,
ServiceProvider, TelegramBot,
)
from ...services import make_immich_provider
@@ -78,6 +78,9 @@ class ImmichCommandHandler(ProviderCommandHandler):
bot: TelegramBot,
tracker: CommandTracker,
config: CommandConfig,
*,
listener: CommandTrackerListener | None = None,
page: int = 1,
) -> CommandResponse | None:
if cmd == "status":
ctx = await _cmd_status(provider, locale)
@@ -96,6 +99,7 @@ class ImmichCommandHandler(ProviderCommandHandler):
return await _cmd_immich(
cmd, args, count, locale, response_mode,
provider, cmd_templates,
listener=listener, page=page,
)
return None
@@ -104,13 +108,25 @@ async def _cmd_immich(
cmd: str, args: str, count: int, locale: str,
response_mode: str, provider: ServiceProvider,
cmd_templates: dict[str, dict[str, str]],
*,
listener: CommandTrackerListener | None = None,
page: int = 1,
) -> CommandResponse | None:
"""Handle commands that need Immich API access and may return media."""
notification_trackers = await get_trackers_for_provider(provider.id)
all_album_ids: list[str] = []
seen: set[str] = set()
for t in notification_trackers:
all_album_ids.extend(t.collection_ids or [])
for aid in (t.collection_ids or []):
if aid not in seen:
seen.add(aid)
all_album_ids.append(aid)
# Per-chat album scope: intersect with listener.allowed_album_ids when set.
if listener is not None and listener.allowed_album_ids is not None:
allowed = set(listener.allowed_album_ids)
all_album_ids = [aid for aid in all_album_ids if aid in allowed]
ext_domain = (provider.config.get("external_domain") or provider.config.get("url", "")).rstrip("/")
@@ -135,9 +151,9 @@ async def _cmd_immich(
result: str | dict[str, Any] | None = None
if cmd == "search":
result = await cmd_search(client, args, all_album_ids, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls)
result = await cmd_search(client, args, all_album_ids, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls, page=page)
elif cmd == "find":
result = await cmd_find(client, args, all_album_ids, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls)
result = await cmd_find(client, args, all_album_ids, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls, page=page)
elif cmd == "person":
result = await cmd_person(client, args, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls)
elif cmd == "place":
@@ -25,11 +25,12 @@ async def cmd_search(
locale: str, response_mode: str,
cmd_templates: dict[str, dict[str, str]],
asset_public_urls: dict[str, str] | None = None,
page: int = 1,
) -> str | dict[str, Any]:
"""Handle /search command."""
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "search", "query": ""})
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count)
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count, page=page)
_enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "search", args, locale, response_mode, client, cmd_templates)
@@ -39,11 +40,12 @@ async def cmd_find(
locale: str, response_mode: str,
cmd_templates: dict[str, dict[str, str]],
asset_public_urls: dict[str, str] | None = None,
page: int = 1,
) -> str | dict[str, Any]:
"""Handle /find command."""
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "find", "query": ""})
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count)
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count, page=page)
_enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "find", args, locale, response_mode, client, cmd_templates)
@@ -52,6 +52,9 @@ class NutCommandHandler(ProviderCommandHandler):
bot: TelegramBot,
tracker: CommandTracker,
config: CommandConfig,
*,
listener: Any = None,
page: int = 1,
) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd)
if fn is None:
@@ -69,6 +69,9 @@ class PlankaCommandHandler(ProviderCommandHandler):
bot: TelegramBot,
tracker: CommandTracker,
config: CommandConfig,
*,
listener: Any = None,
page: int = 1,
) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd)
if fn is None:
@@ -84,11 +84,26 @@ async def migrate_schema(engine: AsyncEngine) -> None:
("provider_id", "ALTER TABLE event_log ADD COLUMN provider_id INTEGER"),
("provider_name", "ALTER TABLE event_log ADD COLUMN provider_name TEXT DEFAULT ''"),
("assets_count", "ALTER TABLE event_log ADD COLUMN assets_count INTEGER DEFAULT 0"),
("user_id", "ALTER TABLE event_log ADD COLUMN user_id INTEGER"),
("action_id", "ALTER TABLE event_log ADD COLUMN action_id INTEGER"),
("action_name", "ALTER TABLE event_log ADD COLUMN action_name TEXT DEFAULT ''"),
]:
if not await _has_column(conn, "event_log", col):
await conn.execute(text(sql))
logger.info("Added %s column to event_log table", col)
# Backfill user_id from notification_tracker for legacy rows.
# Safe to run repeatedly: only touches rows where user_id is still NULL.
await conn.execute(text("""
UPDATE event_log
SET user_id = (
SELECT user_id FROM notification_tracker
WHERE notification_tracker.id = event_log.notification_tracker_id
)
WHERE event_log.user_id IS NULL
AND event_log.notification_tracker_id IS NOT NULL
"""))
# Add commands_config to telegram_bot if missing
if await _has_table(conn, "telegram_bot"):
if not await _has_column(conn, "telegram_bot", "commands_config"):
@@ -129,6 +144,14 @@ async def migrate_schema(engine: AsyncEngine) -> None:
)
logger.info("Added command_template_config_id column to command_config table")
# Add allowed_album_ids (per-chat album scope) to command_tracker_listener
if await _has_table(conn, "command_tracker_listener"):
if not await _has_column(conn, "command_tracker_listener", "allowed_album_ids"):
await conn.execute(
text("ALTER TABLE command_tracker_listener ADD COLUMN allowed_album_ids TEXT")
)
logger.info("Added allowed_album_ids column to command_tracker_listener table")
# Add date_only_format to template_config if missing
if await _has_table(conn, "template_config"):
if not await _has_column(conn, "template_config", "date_only_format"):
@@ -467,6 +467,11 @@ class CommandTrackerListener(SQLModel, table=True):
)
listener_type: str # e.g. "telegram_bot"
listener_id: int
# Optional per-chat album scope. None = inherit from tracker (use all).
# When set, only these album/collection ids are queryable from this chat.
allowed_album_ids: list[str] | None = Field(
default=None, sa_column=Column(JSON, nullable=True),
)
created_at: datetime = Field(default_factory=_utcnow)
@@ -476,6 +481,10 @@ class EventLog(SQLModel, table=True):
__tablename__ = "event_log"
id: int | None = Field(default=None, primary_key=True)
# Owner. Indexed for the dashboard events query. Nullable only because
# historical rows (pre-user_id column) may have no owner; new rows always
# set this directly.
user_id: int | None = Field(default=None, foreign_key="user.id", index=True)
# Python attr stays as tracker_id for backward compat; DB column is notification_tracker_id
tracker_id: int | None = Field(
default=None,
@@ -484,6 +493,13 @@ class EventLog(SQLModel, table=True):
sa_column_kwargs={"name": "notification_tracker_id"},
)
tracker_name: str = Field(default="")
# Links an event back to an Action when the event was emitted by the
# action runner (``event_type`` starts with ``action_``). Null for
# notification-tracker events.
action_id: int | None = Field(
default=None, foreign_key="action.id", index=True,
)
action_name: str = Field(default="")
provider_id: int | None = Field(default=None, index=True)
provider_name: str = Field(default="")
event_type: str = Field(index=True)
@@ -110,6 +110,10 @@ async def _seed_provider_command_template(
await session.flush()
else:
config = configs[0]
if config.name != name or config.description != description:
config.name = name
config.description = description
session.add(config)
for locale in ("en", "ru"):
slots = load_default_command_templates(locale, provider_type=provider_type)
@@ -166,7 +170,7 @@ async def _seed_default_command_templates() -> None:
engine = get_engine()
async with AsyncSession(engine) as session:
await _seed_provider_command_template(
session, "immich", "Default Commands", "Default Immich command templates",
session, "immich", "Default Immich Commands", "Default Immich command templates",
)
await _seed_provider_command_template(
session, "gitea", "Default Gitea Commands", "Default Gitea command templates",
@@ -242,7 +246,7 @@ async def _seed_default_tracking_configs() -> None:
"provider_type": "immich",
"name": "Default Immich",
"track_assets_added": True,
"track_assets_removed": True,
"track_assets_removed": False,
"track_collection_renamed": True,
"track_collection_deleted": True,
"track_sharing_changed": False,
@@ -251,7 +255,7 @@ async def _seed_default_tracking_configs() -> None:
"provider_type": "google_photos",
"name": "Default Google Photos",
"track_assets_added": True,
"track_assets_removed": True,
"track_assets_removed": False,
"track_collection_renamed": True,
"track_collection_deleted": True,
"track_sharing_changed": False,
@@ -66,6 +66,9 @@ async def lifespan(app: FastAPI):
await migrate_user_token_version(engine)
from .database.seeds import seed_all
await seed_all()
# Apply any pending restore staged via /api/backup/prepare-restore
from .services.pending_restore import apply_pending_restore_if_any
await apply_pending_restore_if_any()
# Configure webhook secret from DB setting (falls back to env var)
from sqlmodel.ext.asyncio.session import AsyncSession as _AS
from .api.app_settings import get_setting as _get_setting
@@ -16,6 +16,7 @@ from ..database.models import (
Action,
ActionExecution,
ActionRule,
EventLog,
ServiceProvider,
)
@@ -115,13 +116,42 @@ async def run_action(
execution.error = action_result.error or ""
session.add(execution)
# Update action last_run metadata (skip for dry runs)
# Update action last_run metadata + emit a dashboard EventLog row
# (skip both for dry runs — dashboards should not count previews).
if not is_dry_run:
action = await session.get(Action, action_id)
if action:
action.last_run_at = datetime.now(timezone.utc)
action.last_run_status = execution.status if execution else ""
session.add(action)
provider = await session.get(ServiceProvider, action.provider_id)
status_str = execution.status if execution else "success"
event_type = f"action_{status_str}" # action_success|partial|failed
session.add(EventLog(
user_id=action.user_id,
tracker_id=None,
tracker_name="",
action_id=action.id,
action_name=action.name,
provider_id=provider.id if provider else None,
provider_name=(provider.name if provider else "") or "",
event_type=event_type,
collection_id=str(action.id),
# ``collection_name`` is what the dashboard row shows as the
# event subject; use the action name so the row is readable
# without a separate action_name renderer.
collection_name=action.name,
assets_count=action_result.total_items_affected,
details={
"action_type": action.action_type,
"trigger": trigger,
"rules_processed": action_result.rules_processed,
"rules_succeeded": action_result.rules_succeeded,
"rules_failed": action_result.rules_failed,
"error": action_result.error or "",
"execution_id": execution_id,
},
))
await session.commit()
@@ -298,13 +298,82 @@ async def send_to_receiver(target: NotificationTarget, receiver_config: dict, me
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
"""Send a simple test message. For broadcast targets, fans out to all children."""
"""Send a simple test message. For broadcast targets, fans out to all children.
For Telegram targets, per-receiver locale (TargetReceiver.locale or
TelegramChat.language_override/language_code) is resolved individually so
each chat receives the message in its own configured language.
"""
if target.type == "broadcast":
return await _send_broadcast_test(target, locale)
if target.type == "telegram":
return await _send_telegram_test_per_receiver(target, default_locale=locale)
message = _get_test_message(locale, target.type)
return await send_to_target(target, message)
async def _send_telegram_test_per_receiver(
target: NotificationTarget, default_locale: str = "en",
) -> dict:
"""Send a test message to each Telegram receiver in its own resolved locale."""
from notify_bridge_core.notifications.telegram.client import TelegramClient
from ..database.models import TargetReceiver, TelegramChat
from .http_session import get_http_session
bot_token = target.config.get("bot_token")
bot_id = target.config.get("bot_id")
disable_preview = target.config.get("disable_url_preview", False)
if not bot_token:
return {"success": False, "error": "Missing bot_token"}
engine = get_engine()
async with AsyncSession(engine) as session:
recv_rows = (await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.enabled == True,
)
)).all()
if not recv_rows:
return {"success": False, "error": "No receivers configured"}
# Resolve per-receiver locale
chat_ids = [str(r.config.get("chat_id", "")) for r in recv_rows if r.config.get("chat_id")]
chat_locale_map: dict[str, str] = {}
if bot_id and chat_ids:
chat_rows = (await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id.in_(chat_ids),
)
)).all()
for chat in chat_rows:
override = (
getattr(chat, "language_override", "") or
getattr(chat, "language_code", "") or ""
)
if override:
chat_locale_map[chat.chat_id] = override[:2].lower()
http = await get_http_session()
client = TelegramClient(http, bot_token)
results: list[dict] = []
for r in recv_rows:
chat_id = str(r.config.get("chat_id", ""))
if not chat_id:
continue
explicit = getattr(r, "locale", "") or ""
locale = explicit or chat_locale_map.get(chat_id) or default_locale
message = _get_test_message(locale[:2].lower(), "telegram")
results.append(await client.send_message(
chat_id=chat_id,
text=message,
disable_web_page_preview=bool(disable_preview),
))
return _aggregate(results)
async def _send_broadcast_test(target: NotificationTarget, locale: str) -> dict:
"""Send test notifications to all child targets of a broadcast target."""
child_ids = target.config.get("child_target_ids", [])
@@ -0,0 +1,162 @@
"""Startup hook that applies a pending restore prepared via the backup API.
When an admin uploads a backup via /api/backup/prepare-restore, the file is
staged at data/pending_restore.json and marker rows are written to AppSetting.
This module is invoked during app startup (after migrations + seeds) to
atomically apply that pending restore if present before the server begins
serving requests.
If the apply fails, the pending file is kept so the operator can inspect it
and markers are updated to record the last error. On success, the staged file
is archived under data/applied_restores/<timestamp>.json and markers are
cleared.
"""
from __future__ import annotations
import json
import logging
import shutil
from datetime import datetime, timezone
from sqlmodel.ext.asyncio.session import AsyncSession
from ..api.backup import (
PENDING_RESTORE_CONFLICT_KEY,
PENDING_RESTORE_PATH_KEY,
PENDING_RESTORE_UPLOADED_AT_KEY,
PENDING_RESTORE_UPLOADED_BY_KEY,
_applied_restores_dir,
_pending_restore_path,
)
from ..database.engine import get_engine
from ..database.models import AppSetting
from .backup_schema import BackupFile, ConflictMode
from .backup_service import import_backup
_LOGGER = logging.getLogger(__name__)
PENDING_RESTORE_LAST_ERROR_KEY = "pending_restore_last_error"
PENDING_RESTORE_LAST_APPLIED_KEY = "pending_restore_last_applied"
async def apply_pending_restore_if_any() -> None:
"""Apply a staged restore if one exists. Idempotent and safe to call at startup."""
engine = get_engine()
async with AsyncSession(engine) as session:
path_row = await session.get(AppSetting, PENDING_RESTORE_PATH_KEY)
if not path_row or not path_row.value:
return
pending_path = _pending_restore_path()
if not pending_path.exists():
_LOGGER.warning(
"Pending-restore marker present but file missing at %s — clearing marker",
pending_path,
)
await _clear_markers(session)
await session.commit()
return
conflict_row = await session.get(AppSetting, PENDING_RESTORE_CONFLICT_KEY)
conflict_mode = ConflictMode(conflict_row.value) if conflict_row and conflict_row.value else ConflictMode.SKIP
uploaded_by_row = await session.get(AppSetting, PENDING_RESTORE_UPLOADED_BY_KEY)
uploaded_by = uploaded_by_row.value if uploaded_by_row else "admin"
try:
raw = json.loads(pending_path.read_text(encoding="utf-8"))
backup = BackupFile.model_validate(raw)
except Exception as err: # noqa: BLE001
_LOGGER.exception("Pending-restore file unreadable")
await _record_error(session, f"Unreadable backup: {err}")
await session.commit()
return
# Resolve the target user: first admin (restore is cross-user).
# The backup carries its own user_id per-record, so this is mostly
# used for provenance.
from sqlmodel import select
from ..database.models import User
admin_row = (await session.exec(select(User).where(User.role == "admin"))).first()
if not admin_row:
_LOGGER.error("No admin user found; refusing to apply pending restore")
await _record_error(session, "No admin user available to own the restore")
await session.commit()
return
try:
result = await import_backup(session, admin_row.id, backup, conflict_mode)
except Exception as err: # noqa: BLE001
_LOGGER.exception("Pending-restore apply failed")
await _record_error(session, str(err))
await session.commit()
return
# Archive the file
archive_dir = _applied_restores_dir()
archive_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
archived_name = f"applied-{ts}.json"
try:
shutil.move(str(pending_path), str(archive_dir / archived_name))
except Exception as err: # noqa: BLE001
_LOGGER.warning("Could not archive applied restore file: %s", err)
# Still consider the apply a success; just best-effort cleanup
try:
pending_path.unlink()
except Exception:
pass
await _clear_markers(session)
applied_summary = {
"applied_at": datetime.now(timezone.utc).isoformat(),
"uploaded_by": uploaded_by,
"archived_file": archived_name,
"stats": result.model_dump() if hasattr(result, "model_dump") else {},
}
await _set_setting(
session,
PENDING_RESTORE_LAST_APPLIED_KEY,
json.dumps(applied_summary, default=str),
)
# Clear any prior error marker.
err_row = await session.get(AppSetting, PENDING_RESTORE_LAST_ERROR_KEY)
if err_row:
await session.delete(err_row)
await session.commit()
_LOGGER.info(
"Applied pending restore (uploaded by %s): %s",
uploaded_by, applied_summary["stats"],
)
async def _clear_markers(session: AsyncSession) -> None:
for key in (
PENDING_RESTORE_PATH_KEY,
PENDING_RESTORE_CONFLICT_KEY,
PENDING_RESTORE_UPLOADED_AT_KEY,
PENDING_RESTORE_UPLOADED_BY_KEY,
):
row = await session.get(AppSetting, key)
if row:
await session.delete(row)
async def _record_error(session: AsyncSession, message: str) -> None:
await _set_setting(
session,
PENDING_RESTORE_LAST_ERROR_KEY,
json.dumps({
"at": datetime.now(timezone.utc).isoformat(),
"message": message[:2048],
}),
)
async def _set_setting(session: AsyncSession, key: str, value: str) -> None:
row = await session.get(AppSetting, key)
if row:
row.value = value
else:
row = AppSetting(key=key, value=value)
session.add(row)
@@ -29,7 +29,8 @@ _SAMPLE_ASSET = {
"public_url": "https://immich.example.com/share/abc123/photos/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"download_url": "https://immich.example.com/api/assets/abc123/original",
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
"file_size": 3_500_000, # 3.5 MB
"file_size": 3_500_000, # 3.5 MB — original asset bytes
"playback_size": None, # photos are sent as-is, no transcoded variant
"oversized": False,
}
@@ -43,7 +44,8 @@ _SAMPLE_VIDEO_ASSET = {
"photo_url": None,
"public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef",
"playback_url": "https://immich.example.com/api/assets/def456/video",
"file_size": 75_000_000, # 75 MB — exceeds Telegram's 50 MB limit
"file_size": 180_000_000, # 180 MB — original HEVC
"playback_size": 62_000_000, # 62 MB transcoded — exceeds Telegram's 50 MB limit
"oversized": True,
}
@@ -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
@@ -28,6 +28,16 @@ _LOGGER = logging.getLogger(__name__)
# Track last update_id per bot to use as offset
_last_update_id: dict[int, int] = {}
# Throttle auto-reclaim attempts so we don't hammer deleteWebhook when a
# stubborn external instance keeps re-setting the webhook. (bot_id → unix ts)
_last_webhook_reclaim_at: dict[int, float] = {}
_WEBHOOK_RECLAIM_COOLDOWN_SECONDS = 60.0
# Phrase Telegram uses in the 409 response description for the
# "webhook is active" conflict. Matched case-insensitively so we don't
# depend on exact wording.
_WEBHOOK_CONFLICT_PHRASE = "webhook is active"
async def _get_bot_ids_with_active_listeners() -> set[int]:
"""Return bot IDs that have at least one active command tracker listener.
@@ -141,6 +151,64 @@ def unschedule_bot_polling(bot_id: int) -> None:
_LOGGER.info("Stopped polling for bot %d", bot_id)
async def _handle_webhook_conflict(bot_id: int, bot_token: str, description: str) -> None:
"""Reclaim a bot stuck behind an active webhook set by another instance.
Telegram's ``getUpdates`` returns 409 ``Conflict: can't use getUpdates
method while webhook is active`` whenever a webhook is currently
registered for the bot. Since this bot row has ``update_mode="polling"``
in our DB (that's the only reason we're polling it), the user's intent
is polling, so we drop the webhook and resume. Throttled to once per
minute per bot so a rival instance constantly re-registering the
webhook doesn't trigger a reclaim storm.
"""
import time
now = time.time()
last = _last_webhook_reclaim_at.get(bot_id, 0.0)
if now - last < _WEBHOOK_RECLAIM_COOLDOWN_SECONDS:
# Already logged recently; stay quiet until cooldown expires so the
# user gets one clear warning line per minute, not one every 3s.
return
_last_webhook_reclaim_at[bot_id] = now
from .http_session import get_http_session
http = await get_http_session()
client = TelegramClient(http, bot_token)
# Surface which URL stole the bot so the user can tell where it came from.
conflicting_url = ""
try:
info = await client.get_webhook_info()
if info.get("success"):
conflicting_url = info.get("result", {}).get("url", "") or ""
except Exception as err: # noqa: BLE001
_LOGGER.debug("getWebhookInfo during conflict recovery failed: %s", err)
_LOGGER.warning(
"Bot %d: webhook is active (url=%r) but this instance is in polling "
"mode — calling deleteWebhook to reclaim. Telegram said: %s",
bot_id, conflicting_url, description,
)
try:
del_result = await client.delete_webhook()
if del_result.get("success"):
_LOGGER.warning(
"Bot %d: webhook cleared; polling will resume on next tick",
bot_id,
)
# Reset offset so we don't skip updates that accumulated during the
# conflict window (Telegram held them until a client acknowledged).
_last_update_id.pop(bot_id, None)
else:
_LOGGER.error(
"Bot %d: deleteWebhook failed: %s",
bot_id, del_result.get("error"),
)
except Exception as err: # noqa: BLE001
_LOGGER.error("Bot %d: deleteWebhook raised: %s", bot_id, err)
async def _poll_bot(bot_id: int) -> None:
"""Fetch updates from Telegram and process them."""
engine = get_engine()
@@ -167,6 +235,15 @@ async def _poll_bot(bot_id: int) -> None:
offset=offset + 1 if offset else None, limit=50,
)
if not result.get("success"):
err_text = str(result.get("error") or "")
# Detect the webhook-is-active conflict: another instance (or a
# stale registration) owns this bot's delivery, so getUpdates
# returns 409 and we get zero updates forever. Reclaim it —
# but only for bots the user explicitly set to polling mode.
if _WEBHOOK_CONFLICT_PHRASE in err_text.lower():
await _handle_webhook_conflict(bot_id, bot_token, err_text)
else:
_LOGGER.debug("Polling error for bot %d: %s", bot_id, err_text)
return
updates = result.get("result", [])
except Exception as e:
@@ -79,7 +79,8 @@ async def dispatch_test_notification(
if locale_map:
template_slots = {EventType.SCHEDULED_MESSAGE.value: locale_map}
# Resolve target config + receivers (same as watcher)
# Resolve target config + receivers (same as watcher — this already sets
# each receiver.locale from TargetReceiver.locale or TelegramChat override)
resolved = await _resolve_target(session, target)
target_cfg = TargetConfig(
@@ -95,21 +96,47 @@ async def dispatch_test_notification(
receivers=resolved["receivers"],
)
if not template_slots:
return {
"success": False,
"error": (
f"No '{slot_name}' template defined for this target's template config "
f"(locale: {locale}). Add the slot under Template Configs."
),
}
# Fetch assets and build event
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,
)
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,
)
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:
return {"success": False, "error": "No data returned from provider"}
return {
"success": False,
"error": (
"Provider returned no data. Check that the provider is reachable, "
"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" + (" for today" if test_type == "memory" else "")}
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
url_cache, asset_cache = await _get_telegram_caches()
@@ -136,6 +163,13 @@ async def _build_event(
from datetime import datetime, timezone
if provider_type == "immich":
if test_type == "periodic":
return await _build_immich_periodic_event(
provider_config=provider_config,
provider_name=provider_name,
tracker_name=tracker_name,
collection_ids=collection_ids,
)
return await _build_immich_event(
provider_config=provider_config,
provider_name=provider_name,
@@ -237,6 +271,76 @@ async def _build_immich_event(
)
async def _build_immich_periodic_event(
*,
provider_config: dict,
provider_name: str,
tracker_name: str,
collection_ids: list[str],
) -> ServiceEvent | None:
"""Build a periodic-summary event (album stats only, no assets).
Reuses the same shared core utility (`collect_scheduled_assets`) that
scheduled/memory tests use, invoked with limit=0 so we get the full
``collections_extra`` block (album name/url/counts/...) without selecting
any individual assets which is exactly what the
``periodic_summary_message`` template renders.
"""
from datetime import datetime, timezone
from notify_bridge_core.providers.immich import ImmichServiceProvider
from notify_bridge_core.providers.immich.asset_utils import collect_scheduled_assets
from notify_bridge_core.providers.immich.models import ImmichAlbumData, SharedLinkInfo
from .http_session import get_http_session
http_session = await get_http_session()
immich = ImmichServiceProvider(
http_session,
provider_config.get("url", ""),
provider_config.get("api_key", ""),
provider_config.get("external_domain"),
provider_name,
)
if not await immich.connect():
return None
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "")
albums: dict[str, ImmichAlbumData] = {}
shared_links: dict[str, list[SharedLinkInfo]] = {}
for album_id in collection_ids:
album = await immich.client.get_album(album_id)
if album:
albums[album_id] = album
shared_links[album_id] = await immich.client.get_shared_links(album_id)
# limit=0 → returns ([], collections_extra) with full per-album stats.
_assets, collections_extra = collect_scheduled_assets(
albums, shared_links, ext_domain,
limit=0,
asset_type="all",
favorite_only=False,
min_rating=0,
is_memory=False,
)
first_col = collections_extra[0] if collections_extra else {}
return ServiceEvent(
event_type=EventType.SCHEDULED_MESSAGE,
provider_type=ServiceProviderType.IMMICH,
provider_name=provider_name,
collection_id=collection_ids[0] if collection_ids else "",
collection_name=first_col.get("name", tracker_name),
timestamp=datetime.now(timezone.utc),
added_assets=[],
added_count=0,
extra={
"collections": collections_extra,
"albums": collections_extra,
**(first_col if first_col else {}),
},
)
async def _build_native_memory_event(
immich,
ext_domain: str,
@@ -191,6 +191,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
for event in events:
assets_count = event.added_count or event.removed_count or 0
log = EventLog(
user_id=tracker.user_id,
tracker_id=tracker_id,
tracker_name=tracker.name,
provider_id=provider.id,