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; 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 { function getToken(): string | null {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
return localStorage.getItem('access_token'); return localStorage.getItem('access_token');
@@ -106,7 +137,17 @@ export async function api<T = any>(
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText })); 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(); return res.json();
@@ -114,3 +155,57 @@ export async function api<T = any>(
clearTimeout(timeout); 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"> <script lang="ts">
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { parseDate } from '$lib/api';
import MdiIcon from './MdiIcon.svelte'; import MdiIcon from './MdiIcon.svelte';
interface DayData { interface DayData {
@@ -47,7 +48,7 @@
const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0))); const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0)));
function formatDate(dateStr: string): string { 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' }); 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_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: '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: '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) --- // --- Sort filter (dashboard) ---
+39 -2
View File
@@ -64,6 +64,8 @@
"activeTrackers": "Active Trackers", "activeTrackers": "Active Trackers",
"targets": "Targets", "targets": "Targets",
"recentEvents": "Events", "recentEvents": "Events",
"clearEvents": "Clear",
"confirmClearEvents": "Delete all event log entries? This cannot be undone.",
"chart": "Event chart", "chart": "Event chart",
"noEvents": "No events yet. Create a tracker to start monitoring.", "noEvents": "No events yet. Create a tracker to start monitoring.",
"loading": "Loading...", "loading": "Loading...",
@@ -76,6 +78,9 @@
"collectionRenamed": "collection renamed", "collectionRenamed": "collection renamed",
"collectionDeleted": "collection deleted", "collectionDeleted": "collection deleted",
"sharingChanged": "sharing changed", "sharingChanged": "sharing changed",
"actionSuccess": "action run",
"actionPartial": "action partial",
"actionFailed": "action failed",
"searchEvents": "Search events...", "searchEvents": "Search events...",
"allEvents": "All Events", "allEvents": "All Events",
"filterAssetsAdded": "Assets Added", "filterAssetsAdded": "Assets Added",
@@ -83,6 +88,9 @@
"filterRenamed": "Renamed", "filterRenamed": "Renamed",
"filterDeleted": "Deleted", "filterDeleted": "Deleted",
"filterSharingChanged": "Sharing Changed", "filterSharingChanged": "Sharing Changed",
"filterActionSuccess": "Action Success",
"filterActionPartial": "Action Partial",
"filterActionFailed": "Action Failed",
"allProviders": "All Providers", "allProviders": "All Providers",
"newestFirst": "Newest first", "newestFirst": "Newest first",
"oldestFirst": "Oldest first", "oldestFirst": "Oldest first",
@@ -365,6 +373,7 @@
"roleAdmin": "Admin", "roleAdmin": "Admin",
"create": "Create User", "create": "Create User",
"delete": "Delete", "delete": "Delete",
"edit": "Edit user",
"confirmDelete": "Delete this user?", "confirmDelete": "Delete this user?",
"joined": "joined", "joined": "joined",
"noUsers": "No users found" "noUsers": "No users found"
@@ -785,13 +794,21 @@
"disabled": "Disabled", "disabled": "Disabled",
"noListeners": "No listeners attached.", "noListeners": "No listeners attached.",
"selectBot": "Select bot...", "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": { "snackbar": {
"showDetails": "Show details", "showDetails": "Show details",
"hideDetails": "Hide details" "hideDetails": "Hide details"
}, },
"snack": { "snack": {
"eventsCleared": "{count} event(s) cleared",
"providerSaved": "Provider saved", "providerSaved": "Provider saved",
"providerDeleted": "Provider deleted", "providerDeleted": "Provider deleted",
"trackerCreated": "Tracker created", "trackerCreated": "Tracker created",
@@ -810,6 +827,7 @@
"botDeleted": "Bot deleted", "botDeleted": "Bot deleted",
"userCreated": "User created", "userCreated": "User created",
"userDeleted": "User deleted", "userDeleted": "User deleted",
"userUpdated": "User updated",
"passwordChanged": "Password changed", "passwordChanged": "Password changed",
"copied": "Copied to clipboard", "copied": "Copied to clipboard",
"genericError": "Something went wrong", "genericError": "Something went wrong",
@@ -827,6 +845,7 @@
"commandTrackerDisabled": "Command tracker disabled", "commandTrackerDisabled": "Command tracker disabled",
"listenerAdded": "Listener added", "listenerAdded": "Listener added",
"listenerRemoved": "Listener removed", "listenerRemoved": "Listener removed",
"listenerScopeSaved": "Scope updated",
"cmdTemplateSaved": "Command template saved", "cmdTemplateSaved": "Command template saved",
"cmdTemplateDeleted": "Command template deleted", "cmdTemplateDeleted": "Command template deleted",
"emailBotCreated": "Email bot created", "emailBotCreated": "Email bot created",
@@ -848,6 +867,8 @@
"description": "Description", "description": "Description",
"close": "Close", "close": "Close",
"confirm": "Confirm", "confirm": "Confirm",
"cannotDelete": "Cannot delete",
"blockedByIntro": "Referenced by:",
"error": "Error", "error": "Error",
"success": "Success", "success": "Success",
"none": "None", "none": "None",
@@ -960,6 +981,9 @@
"renamed": "Album was renamed", "renamed": "Album was renamed",
"deleted": "Album was deleted", "deleted": "Album was deleted",
"sharingChanged": "Album sharing toggled", "sharingChanged": "Album sharing toggled",
"actionSuccess": "Scheduled action completed",
"actionPartial": "Scheduled action partially succeeded",
"actionFailed": "Scheduled action failed",
"newestFirst": "Most recent events on top", "newestFirst": "Most recent events on top",
"oldestFirst": "Oldest events on top", "oldestFirst": "Oldest events on top",
"chatActionNone": "No indicator shown", "chatActionNone": "No indicator shown",
@@ -1021,6 +1045,7 @@
"name": "Name", "name": "Name",
"schedule": "Schedule", "schedule": "Schedule",
"interval": "Interval", "interval": "Interval",
"cronMode": "Cron expression",
"seconds": "seconds", "seconds": "seconds",
"cronHint": "Standard cron expression (e.g. 0 3 * * * for daily at 3 AM)", "cronHint": "Standard cron expression (e.g. 0 3 * * * for daily at 3 AM)",
"enabled": "Enabled", "enabled": "Enabled",
@@ -1126,6 +1151,18 @@
"savedFiles": "Saved Backups", "savedFiles": "Saved Backups",
"noFiles": "No backup files yet.", "noFiles": "No backup files yet.",
"download": "Download", "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": "Активные трекеры", "activeTrackers": "Активные трекеры",
"targets": "Получатели", "targets": "Получатели",
"recentEvents": "События", "recentEvents": "События",
"clearEvents": "Очистить",
"confirmClearEvents": "Удалить все записи журнала событий? Это действие нельзя отменить.",
"chart": "График событий", "chart": "График событий",
"noEvents": "Событий пока нет. Создайте трекер для отслеживания.", "noEvents": "Событий пока нет. Создайте трекер для отслеживания.",
"loading": "Загрузка...", "loading": "Загрузка...",
@@ -76,6 +78,9 @@
"collectionRenamed": "альбом переименован", "collectionRenamed": "альбом переименован",
"collectionDeleted": "альбом удалён", "collectionDeleted": "альбом удалён",
"sharingChanged": "изменение доступа", "sharingChanged": "изменение доступа",
"actionSuccess": "действие выполнено",
"actionPartial": "действие частично",
"actionFailed": "действие провалено",
"searchEvents": "Поиск событий...", "searchEvents": "Поиск событий...",
"allEvents": "Все события", "allEvents": "Все события",
"filterAssetsAdded": "Добавление файлов", "filterAssetsAdded": "Добавление файлов",
@@ -83,6 +88,9 @@
"filterRenamed": "Переименование", "filterRenamed": "Переименование",
"filterDeleted": "Удаление", "filterDeleted": "Удаление",
"filterSharingChanged": "Изменение доступа", "filterSharingChanged": "Изменение доступа",
"filterActionSuccess": "Действие выполнено",
"filterActionPartial": "Действие частично",
"filterActionFailed": "Действие провалено",
"allProviders": "Все провайдеры", "allProviders": "Все провайдеры",
"newestFirst": "Сначала новые", "newestFirst": "Сначала новые",
"oldestFirst": "Сначала старые", "oldestFirst": "Сначала старые",
@@ -365,6 +373,7 @@
"roleAdmin": "Администратор", "roleAdmin": "Администратор",
"create": "Создать", "create": "Создать",
"delete": "Удалить", "delete": "Удалить",
"edit": "Редактировать пользователя",
"confirmDelete": "Удалить этого пользователя?", "confirmDelete": "Удалить этого пользователя?",
"joined": "зарегистрирован", "joined": "зарегистрирован",
"noUsers": "Пользователи не найдены" "noUsers": "Пользователи не найдены"
@@ -785,13 +794,21 @@
"disabled": "Отключён", "disabled": "Отключён",
"noListeners": "Нет подключённых слушателей.", "noListeners": "Нет подключённых слушателей.",
"selectBot": "Выберите бота...", "selectBot": "Выберите бота...",
"listenerType": "telegram_bot" "listenerType": "telegram_bot",
"editScope": "Изменить область альбомов",
"scopeAll": "все альбомы",
"albumsShort": "альбомов",
"scopeTitle": "Область альбомов для этого чата",
"scopeDescription": "Ограничить, какие отслеживаемые альбомы доступны в этом чате через команды. Оставьте \"наследовать\", чтобы разрешить все альбомы трекера.",
"scopeInherit": "Наследовать: разрешить все отслеживаемые альбомы",
"noCollections": "Нет доступных альбомов."
}, },
"snackbar": { "snackbar": {
"showDetails": "Показать детали", "showDetails": "Показать детали",
"hideDetails": "Скрыть детали" "hideDetails": "Скрыть детали"
}, },
"snack": { "snack": {
"eventsCleared": "Очищено событий: {count}",
"providerSaved": "Провайдер сохранён", "providerSaved": "Провайдер сохранён",
"providerDeleted": "Провайдер удалён", "providerDeleted": "Провайдер удалён",
"trackerCreated": "Трекер создан", "trackerCreated": "Трекер создан",
@@ -810,6 +827,7 @@
"botDeleted": "Бот удалён", "botDeleted": "Бот удалён",
"userCreated": "Пользователь создан", "userCreated": "Пользователь создан",
"userDeleted": "Пользователь удалён", "userDeleted": "Пользователь удалён",
"userUpdated": "Пользователь обновлён",
"passwordChanged": "Пароль изменён", "passwordChanged": "Пароль изменён",
"copied": "Скопировано", "copied": "Скопировано",
"genericError": "Что-то пошло не так", "genericError": "Что-то пошло не так",
@@ -827,6 +845,7 @@
"commandTrackerDisabled": "Трекер команд отключён", "commandTrackerDisabled": "Трекер команд отключён",
"listenerAdded": "Слушатель добавлен", "listenerAdded": "Слушатель добавлен",
"listenerRemoved": "Слушатель удалён", "listenerRemoved": "Слушатель удалён",
"listenerScopeSaved": "Область обновлена",
"cmdTemplateSaved": "Шаблон команд сохранён", "cmdTemplateSaved": "Шаблон команд сохранён",
"cmdTemplateDeleted": "Шаблон команд удалён", "cmdTemplateDeleted": "Шаблон команд удалён",
"emailBotCreated": "Email бот создан", "emailBotCreated": "Email бот создан",
@@ -848,6 +867,8 @@
"description": "Описание", "description": "Описание",
"close": "Закрыть", "close": "Закрыть",
"confirm": "Подтвердить", "confirm": "Подтвердить",
"cannotDelete": "Невозможно удалить",
"blockedByIntro": "На объект ссылаются:",
"error": "Ошибка", "error": "Ошибка",
"success": "Успешно", "success": "Успешно",
"none": "Нет", "none": "Нет",
@@ -960,6 +981,9 @@
"renamed": "Альбом переименован", "renamed": "Альбом переименован",
"deleted": "Альбом удалён", "deleted": "Альбом удалён",
"sharingChanged": "Изменён доступ к альбому", "sharingChanged": "Изменён доступ к альбому",
"actionSuccess": "Запланированное действие выполнено",
"actionPartial": "Запланированное действие выполнено частично",
"actionFailed": "Запланированное действие провалено",
"newestFirst": "Сначала новые события", "newestFirst": "Сначала новые события",
"oldestFirst": "Сначала старые события", "oldestFirst": "Сначала старые события",
"chatActionNone": "Индикатор не показывается", "chatActionNone": "Индикатор не показывается",
@@ -1021,6 +1045,7 @@
"name": "Название", "name": "Название",
"schedule": "Расписание", "schedule": "Расписание",
"interval": "Интервал", "interval": "Интервал",
"cronMode": "Cron выражение",
"seconds": "секунд", "seconds": "секунд",
"cronHint": "Стандартное cron-выражение (напр. 0 3 * * * — ежедневно в 3:00)", "cronHint": "Стандартное cron-выражение (напр. 0 3 * * * — ежедневно в 3:00)",
"enabled": "Включено", "enabled": "Включено",
@@ -1126,6 +1151,18 @@
"savedFiles": "Сохранённые бэкапы", "savedFiles": "Сохранённые бэкапы",
"noFiles": "Файлов бэкапа пока нет.", "noFiles": "Файлов бэкапа пока нет.",
"download": "Скачать", "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 } interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
const warnings: { id: string; name: string; issue: string }[] = []; 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 { try {
const links = await apiFn<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`); const links = await apiFn<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
const validLink = links.find((l) => l.is_accessible && !l.is_expired); 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 */ } } 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 }; if (warnings.length > 0) return { warnings, proceed: false };
return { proceed: true }; return { proceed: true };
}, },
+2 -2
View File
@@ -384,7 +384,7 @@
</div> </div>
<!-- Global provider filter --> <!-- 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);"> <div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
{#if collapsed} {#if collapsed}
<button onclick={() => { <button onclick={() => {
@@ -564,7 +564,7 @@
onclick={() => mobileMoreOpen = false} role="presentation"></div> 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;" <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 }}> 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);"> <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 /> <IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
</div> </div>
+44 -7
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { api } from '$lib/api'; import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte'; import { providersCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -11,6 +11,8 @@
import EventChart from '$lib/components/EventChart.svelte'; import EventChart from '$lib/components/EventChart.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte'; import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import EntitySelect from '$lib/components/EntitySelect.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 { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { getDescriptor } from '$lib/providers'; import { getDescriptor } from '$lib/providers';
@@ -56,6 +58,21 @@
let eventsLimit = $state(loadEventsPerPage()); let eventsLimit = $state(loadEventsPerPage());
let eventsOffset = $state(0); let eventsOffset = $state(0);
let eventsLoading = $state(false); 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 currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1);
let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0); let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
@@ -191,7 +208,7 @@
] : []); ] : []);
function timeAgo(dateStr: string): string { 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); const mins = Math.floor(diff / 60000);
if (mins < 1) return t('dashboard.justNow'); if (mins < 1) return t('dashboard.justNow');
if (mins < 60) return t('dashboard.minutesAgo').replace('{n}', String(mins)); if (mins < 60) return t('dashboard.minutesAgo').replace('{n}', String(mins));
@@ -206,15 +223,20 @@
collection_renamed: 'dashboard.collectionRenamed', collection_renamed: 'dashboard.collectionRenamed',
collection_deleted: 'dashboard.collectionDeleted', collection_deleted: 'dashboard.collectionDeleted',
sharing_changed: 'dashboard.sharingChanged', sharing_changed: 'dashboard.sharingChanged',
action_success: 'dashboard.actionSuccess',
action_partial: 'dashboard.actionPartial',
action_failed: 'dashboard.actionFailed',
}; };
const eventIcons: Record<string, string> = { const eventIcons: Record<string, string> = {
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus', assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant', collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
}; };
const eventColors: Record<string, string> = { const eventColors: Record<string, string> = {
assets_added: '#059669', assets_removed: '#ef4444', assets_added: '#059669', assets_removed: '#ef4444',
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b', collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
action_success: '#0d9488', action_partial: '#f59e0b', action_failed: '#dc2626',
}; };
</script> </script>
@@ -252,13 +274,23 @@
</div> </div>
<!-- Events section --> <!-- Events section -->
<h3 class="text-base font-semibold mb-3 flex items-center gap-2"> <div class="flex items-center justify-between mb-3">
<MdiIcon name="mdiPulse" size={18} /> <h3 class="text-base font-semibold flex items-center gap-2">
{t('dashboard.recentEvents')} <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} {#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} {/if}
</h3> </div>
<!-- Filters --> <!-- Filters -->
<div class="flex flex-wrap items-center gap-2 mb-4"> <div class="flex flex-wrap items-center gap-2 mb-4">
@@ -370,6 +402,9 @@
{/if} {/if}
{/if} {/if}
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
<style> <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 { 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); } .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 { 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-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); } .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> </style>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api'; import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import type { ActionExecution } from '$lib/types'; import type { ActionExecution } from '$lib/types';
@@ -47,14 +47,14 @@
function formatDate(iso: string | null): string { function formatDate(iso: string | null): string {
if (!iso) return '-'; if (!iso) return '-';
try { try {
return new Date(iso).toLocaleString(); return parseDate(iso).toLocaleString();
} catch { return iso; } } catch { return iso; }
} }
function formatDuration(start: string, end: string | null): string { function formatDuration(start: string, end: string | null): string {
if (!end) return '-'; if (!end) return '-';
try { 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`; if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`; return `${(ms / 1000).toFixed(1)}s`;
} catch { return '-'; } } catch { return '-'; }
+10 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <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 { t, getLocale } from '$lib/i18n';
import { emailBotsCache } from '$lib/stores/caches.svelte'; import { emailBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -58,12 +59,17 @@
finally { emailSubmitting = false; } finally { emailSubmitting = false; }
} }
let blockedBy = $state<BlockedByDetail | null>(null);
function removeEmail(id: number) { function removeEmail(id: number) {
confirmDeleteEmail = { confirmDeleteEmail = {
id, id,
onconfirm: async () => { onconfirm: async () => {
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); } 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; } finally { confirmDeleteEmail = null; }
} }
}; };
@@ -173,3 +179,5 @@
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')} <ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} /> 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"> <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 { t, getLocale } from '$lib/i18n';
import { matrixBotsCache } from '$lib/stores/caches.svelte'; import { matrixBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -56,12 +57,17 @@
finally { matrixSubmitting = false; } finally { matrixSubmitting = false; }
} }
let blockedBy = $state<BlockedByDetail | null>(null);
function removeMatrix(id: number) { function removeMatrix(id: number) {
confirmDeleteMatrix = { confirmDeleteMatrix = {
id, id,
onconfirm: async () => { onconfirm: async () => {
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); } 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; } finally { confirmDeleteMatrix = null; }
} }
}; };
@@ -155,3 +161,5 @@
<ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')} <ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')}
onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} /> 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"> <script lang="ts">
import { slide } from 'svelte/transition'; 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 { t, getLocale } from '$lib/i18n';
import { telegramBotsCache } from '$lib/stores/caches.svelte'; import { telegramBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -64,12 +65,17 @@
finally { submitting = false; } finally { submitting = false; }
} }
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) { function remove(id: number) {
confirmDelete = { confirmDelete = {
id, id,
onconfirm: async () => { onconfirm: async () => {
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); } 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; } finally { confirmDelete = null; }
} }
}; };
@@ -518,3 +524,5 @@
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')} <ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; 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 { t } from '$lib/i18n';
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte'; import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -92,7 +93,10 @@
function openNew() { function openNew() {
form = defaultForm(); 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); const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
if (match) form.command_template_config_id = match.id; if (match) form.command_template_config_id = match.id;
editing = null; editing = null;
@@ -137,6 +141,7 @@
finally { submitting = false; } finally { submitting = false; }
} }
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(cfg: CommandConfig) { function remove(cfg: CommandConfig) {
confirmDelete = { confirmDelete = {
id: cfg.id, id: cfg.id,
@@ -145,7 +150,11 @@
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' }); await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
await load(); await load();
snackSuccess(t('snack.commandConfigDeleted')); 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; } finally { confirmDelete = null; }
} }
}; };
@@ -296,3 +305,5 @@
<ConfirmModal open={confirmDelete !== null} message={t('commandConfig.confirmDelete')} <ConfirmModal open={confirmDelete !== null} message={t('commandConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; 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 { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize'; import { sanitizePreview } from '$lib/sanitize';
import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte'; import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
@@ -189,6 +190,8 @@
function openNew() { function openNew() {
form = defaultForm(); 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; editing = null;
showForm = true; showForm = true;
activeLocale = 'en'; activeLocale = 'en';
@@ -265,6 +268,7 @@
setTimeout(() => refreshAllPreviews(), 100); setTimeout(() => refreshAllPreviews(), 100);
} }
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) { function remove(id: number) {
confirmDelete = { confirmDelete = {
id, id,
@@ -274,6 +278,8 @@
await load(); await load();
snackSuccess(t('snack.cmdTemplateDeleted')); snackSuccess(t('snack.cmdTemplateDeleted'));
} catch (err: any) { } catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; error = err.message;
snackError(err.message); snackError(err.message);
} finally { } finally {
@@ -458,6 +464,8 @@
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')} <ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal --> <!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}> <Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]} {#if showVarsFor && varsRef[showVarsFor]}
@@ -11,6 +11,7 @@
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import Modal from '$lib/components/Modal.svelte';
import IconButton from '$lib/components/IconButton.svelte'; import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte'; import CrossLink from '$lib/components/CrossLink.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte'; import EntitySelect from '$lib/components/EntitySelect.svelte';
@@ -83,7 +84,17 @@
finally { loaded = true; highlightFromUrl(); } 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) { function editTracker(trk: any) {
form = { form = {
name: trk.name, name: trk.name,
@@ -178,6 +189,35 @@
} catch (err: any) { snackError(err.message); } } 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 { function providerName(id: number): string {
return providers.find(p => p.id === id)?.name || '?'; return providers.find(p => p.id === id)?.name || '?';
} }
@@ -289,10 +329,18 @@
<div class="space-y-1"> <div class="space-y-1">
{#each listeners[trk.id] as listener} {#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 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} /> <MdiIcon name="mdiRobot" size={14} />
<CrossLink href="/bots?tab=telegram" icon="mdiRobot" label={listener.name || listener.listener_type} entityId={listener.listener_id} /> <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> <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> </div>
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14} <IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
onclick={() => removeListener(trk.id, listener.id)} variant="danger" /> onclick={() => removeListener(trk.id, listener.id)} variant="danger" />
@@ -321,3 +369,59 @@
<ConfirmModal open={confirmDelete !== null} message={t('commandTracker.confirmDelete')} <ConfirmModal open={confirmDelete !== null} message={t('commandTracker.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> 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"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '$lib/api'; import { api, parseDate } from '$lib/api';
import { t, getLocale } from '$lib/i18n'; import { t, getLocale } from '$lib/i18n';
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte'; import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -136,10 +136,29 @@
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) { if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
_prevProviderId = form.provider_id; _prevProviderId = form.provider_id;
loadCollections(); 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) { async function edit(trk: Tracker) {
form = { form = {
@@ -256,7 +275,7 @@
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
if (!dateStr) return ''; if (!dateStr) return '';
try { try {
const d = new Date(dateStr); const d = parseDate(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} catch (e) { console.warn('Date format error:', e); return ''; } } catch (e) { console.warn('Date format error:', e); return ''; }
} }
+10 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { api } from '$lib/api'; import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte'; import { providersCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -11,6 +11,7 @@
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import IconButton from '$lib/components/IconButton.svelte'; import IconButton from '$lib/components/IconButton.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte'; import IconGridSelect from '$lib/components/IconGridSelect.svelte';
@@ -131,12 +132,17 @@
} }
function startDelete(provider: any) { confirmDelete = provider; } function startDelete(provider: any) { confirmDelete = provider; }
let blockedBy = $state<BlockedByDetail | null>(null);
async function doDelete() { async function doDelete() {
if (!confirmDelete) return; if (!confirmDelete) return;
const id = confirmDelete.id; const id = confirmDelete.id;
confirmDelete = null; confirmDelete = null;
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); } 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> </script>
@@ -280,6 +286,8 @@
<ConfirmModal open={!!confirmDelete} message={t('providers.confirmDelete')} <ConfirmModal open={!!confirmDelete} message={t('providers.confirmDelete')}
onconfirm={doDelete} oncancel={() => confirmDelete = null} /> onconfirm={doDelete} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style> <style>
.health-dot { .health-dot {
width: 10px; width: 10px;
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { api } from '$lib/api'; import { api, parseDate } from '$lib/api';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import type { WebhookPayloadLog } from '$lib/types'; import type { WebhookPayloadLog } from '$lib/types';
@@ -65,7 +65,7 @@
} }
function formatTime(iso: string): string { function formatTime(iso: string): string {
return new Date(iso).toLocaleString(); return parseDate(iso).toLocaleString();
} }
</script> </script>
+164 -24
View File
@@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '$lib/api'; import { api, fetchAuth } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte'; import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte'; import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal.svelte';
@@ -60,15 +59,23 @@
let backupFiles = $state<any[]>([]); let backupFiles = $state<any[]>([]);
let loadingFiles = $state(false); let loadingFiles = $state(false);
let confirmDeleteFile = $state(''); 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 () => { onMount(async () => {
try { try {
const [settings, files] = await Promise.all([ const [settings, files, p] = await Promise.all([
api('/backup/scheduled'), api('/backup/scheduled'),
api('/backup/files'), api('/backup/files'),
api('/backup/pending-restore'),
]); ]);
scheduledSettings = settings; scheduledSettings = settings;
backupFiles = files; backupFiles = files;
pending = p;
} catch (err: any) { } catch (err: any) {
error = err.message; error = err.message;
snackError(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 --- // --- Export ---
async function doExport() { async function doExport() {
if (exportSecrets === 'include') { if (exportSecrets === 'include') {
@@ -120,16 +174,7 @@
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', importFile); formData.append('file', importFile);
const token = localStorage.getItem('access_token'); const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
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}`);
}
validationResult = await res.json(); validationResult = await res.json();
} catch (err: any) { } catch (err: any) {
snackError(err.message); snackError(err.message);
@@ -151,18 +196,15 @@
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', importFile); formData.append('file', importFile);
const token = localStorage.getItem('access_token'); const res = await fetchAuth(`/backup/prepare-restore?conflict_mode=${importConflict}`, {
const res = await fetch(`/api/backup/import?conflict_mode=${importConflict}`, {
method: 'POST', method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData, 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(); importResult = await res.json();
snackSuccess(t('backup.importSuccess')); pending = importResult;
snackSuccess(t('backup.restorePrepared'));
postRestoreModalOpen = true;
importFile = null;
} catch (err: any) { } catch (err: any) {
snackError(err.message); snackError(err.message);
} finally { } finally {
@@ -256,6 +298,33 @@
<Loading /> <Loading />
{:else} {:else}
<ErrorBanner message={error} /> <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"> <div class="space-y-6">
<!-- Export Section --> <!-- Export Section -->
@@ -502,9 +571,14 @@
<MdiIcon name="mdiFolder" size={18} /> <MdiIcon name="mdiFolder" size={18} />
{t('backup.savedFiles')} {t('backup.savedFiles')}
</h3> </h3>
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}> <div class="flex items-center gap-2">
<MdiIcon name="mdiRefresh" size={14} /> <Button size="sm" onclick={createManualBackup} disabled={creatingBackup}>
</button> <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> </div>
{#if backupFiles.length === 0} {#if backupFiles.length === 0}
@@ -568,3 +642,69 @@
onconfirm={() => deleteFile(confirmDeleteFile)} onconfirm={() => deleteFile(confirmDeleteFile)}
oncancel={() => 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"> <script lang="ts">
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { page } from '$app/state'; 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 { t, getLocale } from '$lib/i18n';
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte'; import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -114,7 +115,7 @@
const defaultForm = () => ({ const defaultForm = () => ({
name: '', icon: '', bot_id: 0, bot_token: '', name: '', icon: '', bot_id: 0, bot_token: '',
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50, 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 // Discord/Slack shared settings
username: '', username: '',
// ntfy shared settings // ntfy shared settings
@@ -193,6 +194,10 @@
function openNew() { function openNew() {
form = defaultForm(); form = defaultForm();
formType = activeType || 'telegram'; 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; editing = null;
showTelegramSettings = false; showTelegramSettings = false;
showForm = true; showForm = true;
@@ -289,12 +294,15 @@
} catch (err: any) { snackError(err.message); } } catch (err: any) { snackError(err.message); }
} }
let blockedBy = $state<BlockedByDetail | null>(null);
async function remove(id: number) { async function remove(id: number) {
try { try {
await api(`/targets/${id}`, { method: 'DELETE' }); await api(`/targets/${id}`, { method: 'DELETE' });
await load(); await load();
snackSuccess(t('snack.targetDeleted')); snackSuccess(t('snack.targetDeleted'));
} catch (err: any) { } catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; error = err.message;
snackError(err.message); snackError(err.message);
} }
@@ -529,3 +537,5 @@
onconfirm={() => { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }} onconfirm={() => { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }}
oncancel={() => confirmDeleteReceiver = null} oncancel={() => confirmDeleteReceiver = null}
/> />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; 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 { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize'; import { sanitizePreview } from '$lib/sanitize';
import { templateConfigsCache, capabilitiesCache, supportedLocalesCache } from '$lib/stores/caches.svelte'; import { templateConfigsCache, capabilitiesCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
@@ -208,7 +209,12 @@
finally { loaded = true; highlightFromUrl(); } 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) { function edit(c: TemplateConfig) {
form = { form = {
provider_type: c.provider_type, provider_type: c.provider_type,
@@ -253,12 +259,17 @@
setTimeout(() => refreshAllPreviews(), 100); setTimeout(() => refreshAllPreviews(), 100);
} }
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) { function remove(id: number) {
confirmDelete = { confirmDelete = {
id, id,
onconfirm: async () => { onconfirm: async () => {
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); } 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; } finally { confirmDelete = null; }
} }
}; };
@@ -455,6 +466,8 @@
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')} <ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal --> <!-- 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}> <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]} {#if showVarsFor && varsRef[showVarsFor]}
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; 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 { t } from '$lib/i18n';
import { trackingConfigsCache } from '$lib/stores/caches.svelte'; import { trackingConfigsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -72,12 +73,17 @@
} catch (err: any) { error = err.message; snackError(err.message); } } catch (err: any) { error = err.message; snackError(err.message); }
} }
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) { function remove(id: number) {
confirmDelete = { confirmDelete = {
id, id,
onconfirm: async () => { onconfirm: async () => {
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); } 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; } finally { confirmDelete = null; }
} }
}; };
@@ -257,6 +263,8 @@
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')} <ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style> <style>
.toggle-switch { .toggle-switch {
position: relative; position: relative;
+47 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '$lib/api'; import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { getAuth } from '$lib/auth.svelte'; import { getAuth } from '$lib/auth.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -31,6 +31,13 @@
let resetMsg = $state(''); let resetMsg = $state('');
let resetSuccess = $state(false); 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); onMount(load);
async function load() { async function load() {
try { users = await api('/users'); } try { users = await api('/users'); }
@@ -56,6 +63,20 @@
function openResetPassword(user: any) { function openResetPassword(user: any) {
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false; 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) { async function resetUserPassword(e: SubmitEvent) {
e.preventDefault(); resetMsg = ''; resetSuccess = false; e.preventDefault(); resetMsg = ''; resetSuccess = false;
try { try {
@@ -111,9 +132,10 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="font-medium">{user.username}</p> <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>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
{#if user.id !== auth.user?.id} {#if user.id !== auth.user?.id}
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} /> <IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" /> <IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
@@ -144,5 +166,28 @@
</form> </form>
</Modal> </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')} <ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
@@ -16,6 +16,13 @@ from .ssrf import UnsafeURLError, validate_outbound_url
_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30) _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: def _new_session() -> aiohttp.ClientSession:
"""Per-dispatch aiohttp session with a sane default timeout. """Per-dispatch aiohttp session with a sane default timeout.
@@ -38,6 +45,11 @@ from .receiver import (
) )
from .telegram.cache import TelegramFileCache from .telegram.cache import TelegramFileCache
from .telegram.client import TelegramClient 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 from .webhook.client import WebhookClient
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -146,6 +158,90 @@ class NotificationDispatcher:
return await send_method(target, default_message, event) return await send_method(target, default_message, event)
return {"success": False, "error": f"Unknown target type: {target.type}"} 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( async def _send_telegram(
self, target: TargetConfig, default_message: str, event: ServiceEvent self, target: TargetConfig, default_message: str, event: ServiceEvent
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -172,6 +268,7 @@ class NotificationDispatcher:
external_url = (target.provider_external_url or "").rstrip("/") external_url = (target.provider_external_url or "").rstrip("/")
provider_urls = [u for u in (internal_url, external_url) if u] provider_urls = [u for u in (internal_url, external_url) if u]
assets = [] assets = []
media_assets: list[Any] = [] # aligned with `assets` for preload
for asset in event.added_assets[:max_media]: for asset in event.added_assets[:max_media]:
url = asset.preview_url or asset.thumbnail_url or asset.full_url url = asset.preview_url or asset.thumbnail_url or asset.full_url
if url: if url:
@@ -187,9 +284,16 @@ class NotificationDispatcher:
if asset.extra.get("cache_key"): if asset.extra.get("cache_key"):
asset_entry["cache_key"] = asset.extra["cache_key"] asset_entry["cache_key"] = asset.extra["cache_key"]
assets.append(asset_entry) assets.append(asset_entry)
media_assets.append(asset)
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
async with _new_session() as session: 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( client = TelegramClient(
session, bot_token, session, bot_token,
url_cache=self._url_cache, url_cache=self._url_cache,
@@ -84,10 +84,19 @@ class TelegramFileCache:
if age > self._ttl_seconds: if age > self._ttl_seconds:
return None 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( 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: ) -> None:
if self._data is None: if self._data is None:
self._data = {"files": {}} self._data = {"files": {}}
@@ -99,20 +108,34 @@ class TelegramFileCache:
} }
if thumbhash is not None: if thumbhash is not None:
entry["thumbhash"] = thumbhash entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
self._data["files"][key] = entry self._data["files"][key] = entry
await self._backend.save(self._data) await self._backend.save(self._data)
async def async_set_many( 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: ) -> 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: if not entries:
return return
if self._data is None: if self._data is None:
self._data = {"files": {}} self._data = {"files": {}}
now_iso = datetime.now(timezone.utc).isoformat() 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] = { entry: dict[str, Any] = {
"file_id": file_id, "file_id": file_id,
"type": media_type, "type": media_type,
@@ -120,6 +143,8 @@ class TelegramFileCache:
} }
if thumbhash is not None: if thumbhash is not None:
entry["thumbhash"] = thumbhash entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
self._data["files"][key] = entry self._data["files"][key] = entry
await self._backend.save(self._data) await self._backend.save(self._data)
@@ -6,6 +6,7 @@ import asyncio
import json import json
import logging import logging
import mimetypes import mimetypes
from dataclasses import dataclass
from typing import Any, Callable from typing import Any, Callable
import aiohttp import aiohttp
@@ -29,6 +30,36 @@ _LOGGER = logging.getLogger(__name__)
NotificationResult = dict[str, Any] 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: class TelegramClient:
"""Async Telegram Bot API client for sending notifications with media.""" """Async Telegram Bot API client for sending notifications with media."""
@@ -76,6 +107,94 @@ class TelegramClient:
is_asset = is_asset_cache_key(key) is_asset = is_asset_cache_key(key)
return self._asset_cache if is_asset else self._url_cache 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( async def send_notification(
self, self,
chat_id: str, chat_id: str,
@@ -107,6 +226,7 @@ class TelegramClient:
parse_mode, max_asset_data_size, send_large_photos_as_documents, parse_mode, max_asset_data_size, send_large_photos_as_documents,
assets[0].get("content_type"), assets[0].get("cache_key"), assets[0].get("content_type"), assets[0].get("cache_key"),
download_headers=assets[0].get("headers"), download_headers=assets[0].get("headers"),
preloaded_data=assets[0].get("data"),
) )
if len(assets) == 1 and assets[0].get("type") == "video": if len(assets) == 1 and assets[0].get("type") == "video":
return await self._send_video( return await self._send_video(
@@ -114,28 +234,31 @@ class TelegramClient:
parse_mode, max_asset_data_size, parse_mode, max_asset_data_size,
assets[0].get("content_type"), assets[0].get("cache_key"), assets[0].get("content_type"), assets[0].get("cache_key"),
download_headers=assets[0].get("headers"), download_headers=assets[0].get("headers"),
preloaded_data=assets[0].get("data"),
) )
if len(assets) == 1 and assets[0].get("type", "document") == "document": if len(assets) == 1 and assets[0].get("type", "document") == "document":
url = assets[0].get("url") url = assets[0].get("url")
if not url: if not url:
return {"success": False, "error": "Missing 'url' for document"} return {"success": False, "error": "Missing 'url' for document"}
try: data = assets[0].get("data")
download_url = self._resolve_url(url) if data is None:
dl_headers = assets[0].get("headers") or {} try:
async with self._session.get(download_url, headers=dl_headers) as resp: download_url = self._resolve_url(url)
if resp.status != 200: dl_headers = assets[0].get("headers") or {}
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"} async with self._session.get(download_url, headers=dl_headers) as resp:
data = await resp.read() if resp.status != 200:
if max_asset_data_size is not None and len(data) > max_asset_data_size: return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
return {"success": False, "error": f"Media size exceeds limit"} data = await resp.read()
filename = url.split("/")[-1].split("?")[0] or "file" except aiohttp.ClientError as err:
return await self._send_document( return {"success": False, "error": f"Failed to download media: {err}"}
chat_id, data, filename, caption, reply_to_message_id, if max_asset_data_size is not None and len(data) > max_asset_data_size:
parse_mode, url, assets[0].get("content_type"), return {"success": False, "error": f"Media size exceeds limit"}
assets[0].get("cache_key"), filename = url.split("/")[-1].split("?")[0] or "file"
) return await self._send_document(
except aiohttp.ClientError as err: chat_id, data, filename, caption, reply_to_message_id,
return {"success": False, "error": f"Failed to download media: {err}"} parse_mode, url, assets[0].get("content_type"),
assets[0].get("cache_key"),
)
return await self._send_media_group( return await self._send_media_group(
chat_id, assets, caption, reply_to_message_id, max_group_size, 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, max_asset_data_size: int | None = None, send_large_photos_as_documents: bool = False,
content_type: str | None = None, cache_key: str | None = None, content_type: str | None = None, cache_key: str | None = None,
download_headers: dict[str, str] | None = None, download_headers: dict[str, str] | None = None,
preloaded_data: bytes | None = None,
) -> NotificationResult: ) -> NotificationResult:
if not content_type:
content_type = "image/jpeg"
if not url: if not url:
return {"success": False, "error": "Missing 'url' for photo"} return {"success": False, "error": "Missing 'url' for photo"}
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(url, cache_key) cache, key, thumbhash = self._get_cache_and_key(url, cache_key)
cached = cache.get(key, thumbhash=thumbhash) if cache and key else None
# Check cache
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
if cached and cached.get("file_id"): if cached and cached.get("file_id"):
payload = {"chat_id": chat_id, "photo": cached["file_id"], "parse_mode": parse_mode} cached_result = await self._send_from_cache(
if caption: _PHOTO_KIND, chat_id, cached["file_id"],
payload["caption"] = caption caption, reply_to_message_id, parse_mode,
if reply_to_message_id: )
payload["reply_parameters"] = {"message_id": reply_to_message_id} if cached_result is not None:
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendPhoto" return cached_result
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: data, err = await self._fetch_bytes(url, download_headers, preloaded_data)
download_url = self._resolve_url(url) if data is None:
async with self._session.get(download_url, headers=download_headers or {}) as resp: return {"success": False, "error": f"Failed to download photo: {err}"}
if resp.status != 200:
return {"success": False, "error": f"Failed to download photo: HTTP {resp.status}"}
data = await resp.read()
if max_asset_data_size is not None and len(data) > max_asset_data_size: 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} return {"success": False, "error": "Photo exceeds size limit", "skipped": True}
exceeds_limits, reason, _, _ = check_photo_limits(data) exceeds_limits, reason, _, _ = check_photo_limits(data)
if exceeds_limits: if exceeds_limits:
if send_large_photos_as_documents: 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 await self._send_document(
return {"success": False, "error": f"Photo {reason}", "skipped": True} 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() return await self._upload_media(
form.add_field("chat_id", chat_id) _PHOTO_KIND, chat_id, data,
form.add_field("photo", data, filename="photo.jpg", content_type=content_type) _PHOTO_KIND.default_filename,
form.add_field("parse_mode", parse_mode) content_type or _PHOTO_KIND.default_content_type,
if caption: caption, reply_to_message_id, parse_mode,
form.add_field("caption", caption) cache, key, thumbhash,
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)}
async def _send_video( async def _send_video(
self, chat_id: str, url: str | None, caption: str | None = None, self, chat_id: str, url: str | None, caption: str | None = None,
reply_to_message_id: int | None = None, parse_mode: str = "HTML", reply_to_message_id: int | None = None, parse_mode: str = "HTML",
max_asset_data_size: int | None = None, content_type: str | None = None, max_asset_data_size: int | None = None, content_type: str | None = None,
cache_key: str | None = None, download_headers: dict[str, str] | None = None, cache_key: str | None = None, download_headers: dict[str, str] | None = None,
preloaded_data: bytes | None = None,
) -> NotificationResult: ) -> NotificationResult:
if not content_type:
content_type = "video/mp4"
if not url: if not url:
return {"success": False, "error": "Missing 'url' for video"} return {"success": False, "error": "Missing 'url' for video"}
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(url, cache_key) cache, key, thumbhash = self._get_cache_and_key(url, cache_key)
cached = cache.get(key, thumbhash=thumbhash) if cache and key else None
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
if cached and cached.get("file_id"): if cached and cached.get("file_id"):
payload = {"chat_id": chat_id, "video": cached["file_id"], "parse_mode": parse_mode} cached_result = await self._send_from_cache(
if caption: _VIDEO_KIND, chat_id, cached["file_id"],
payload["caption"] = caption caption, reply_to_message_id, parse_mode,
if reply_to_message_id: )
payload["reply_parameters"] = {"message_id": reply_to_message_id} if cached_result is not None:
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendVideo" return cached_result
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: data, err = await self._fetch_bytes(url, download_headers, preloaded_data)
download_url = self._resolve_url(url) if data is None:
async with self._session.get(download_url, headers=download_headers or {}) as resp: return {"success": False, "error": f"Failed to download video: {err}"}
if resp.status != 200:
return {"success": False, "error": f"Failed to download video: HTTP {resp.status}"}
data = await resp.read()
if max_asset_data_size is not None and len(data) > max_asset_data_size: 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} return {"success": False, "error": "Video exceeds size limit", "skipped": True}
if len(data) > TELEGRAM_MAX_VIDEO_SIZE: 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} return {
"success": False,
"error": f"Video exceeds Telegram's {TELEGRAM_MAX_VIDEO_SIZE // (1024*1024)} MB limit",
"skipped": True,
}
form = FormData() return await self._upload_media(
form.add_field("chat_id", chat_id) _VIDEO_KIND, chat_id, data,
form.add_field("video", data, filename="video.mp4", content_type=content_type) _VIDEO_KIND.default_filename,
form.add_field("parse_mode", parse_mode) content_type or _VIDEO_KIND.default_content_type,
if caption: caption, reply_to_message_id, parse_mode,
form.add_field("caption", caption) cache, key, thumbhash,
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)}
async def _send_document( async def _send_document(
self, chat_id: str, data: bytes, filename: str = "file", self, chat_id: str, data: bytes, filename: str = "file",
@@ -348,50 +423,24 @@ class TelegramClient:
if not content_type: if not content_type:
content_type, _ = mimetypes.guess_type(filename) content_type, _ = mimetypes.guess_type(filename)
if not content_type: 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: return await self._upload_media(
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) _DOCUMENT_KIND, chat_id, data, filename, content_type,
if cached and cached.get("file_id") and cached.get("type") == "document": caption, reply_to_message_id, parse_mode,
payload = {"chat_id": chat_id, "document": cached["file_id"], "parse_mode": parse_mode} cache, key, thumbhash,
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)}
async def _send_media_group( async def _send_media_group(
self, chat_id: str, assets: list[dict[str, str]], 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_caption = caption if chunk_idx == 0 else None
chunk_reply = reply_to_message_id if chunk_idx == 0 else None chunk_reply = reply_to_message_id if chunk_idx == 0 else None
if item.get("type") == "photo": 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": 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: else:
continue continue
if not result.get("success"): if not result.get("success"):
@@ -433,7 +482,8 @@ class TelegramClient:
# Track cache info per media_json entry (in order) so we can map # Track cache info per media_json entry (in order) so we can map
# Telegram response items back to cache keys for newly uploaded items. # Telegram response items back to cache keys for newly uploaded items.
# None = already cached (no need to store), tuple = needs caching. # 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 # Resolve cache hits and collect download tasks in parallel
async def _fetch_asset(idx: int, item: dict) -> tuple[int, dict | None, bytes | None]: 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"): if cached and cached.get("file_id"):
return idx, cached, None 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: try:
download_url = self._resolve_url(url) download_url = self._resolve_url(url)
dl_headers = item.get("headers") or {} dl_headers = item.get("headers") or {}
@@ -500,7 +564,7 @@ class TelegramClient:
ck_is_asset = is_asset_cache_key(ck) ck_is_asset = is_asset_cache_key(ck)
bare_ck = asset_id_from_cache_key(ck) if ck_is_asset else 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 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: else:
continue continue
@@ -523,14 +587,14 @@ class TelegramClient:
all_message_ids.extend(msg.get("message_id") for msg in result_msgs) all_message_ids.extend(msg.get("message_id") for msg in result_msgs)
# Cache file_ids from response — map by position # 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): for i, msg in enumerate(result_msgs):
if i >= len(media_cache_info): if i >= len(media_cache_info):
break break
info = media_cache_info[i] info = media_cache_info[i]
if info is None: if info is None:
continue # was a cache hit, skip continue # was a cache hit, skip
ck, mt, th = info ck, mt, th, sz = info
file_id = None file_id = None
if msg.get("photo"): if msg.get("photo"):
file_id = msg["photo"][-1].get("file_id") file_id = msg["photo"][-1].get("file_id")
@@ -539,7 +603,7 @@ class TelegramClient:
elif msg.get("document"): elif msg.get("document"):
file_id = msg["document"].get("file_id") file_id = msg["document"].get("file_id")
if 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: if cache_entries:
# All entries in a chunk share the same cache backend # 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])) 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: except aiohttp.ClientError as err:
return {"success": False, "error": str(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]: async def get_webhook_info(self) -> dict[str, Any]:
"""Call getWebhookInfo to check current webhook status.""" """Call getWebhookInfo to check current webhook status."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getWebhookInfo" url = f"{TELEGRAM_API_BASE_URL}{self._token}/getWebhookInfo"
@@ -132,8 +132,10 @@ class ImmichActionExecutor(ActionExecutor):
target_album_ids = [single] target_album_ids = [single]
try: try:
# Step 1: Gather candidate assets from criteria # Step 1: Gather candidate assets from criteria. Asset type is
candidate_ids = await self._gather_candidates(criteria) # 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: if not candidate_ids:
return RuleResult( return RuleResult(
@@ -146,6 +148,7 @@ class ImmichActionExecutor(ActionExecutor):
) )
# If no target albums and create_if_missing, create one # 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 not target_album_ids and create_if_missing and create_album_name:
if dry_run: if dry_run:
_LOGGER.info("[DRY RUN] Would create album '%s'", create_album_name) _LOGGER.info("[DRY RUN] Would create album '%s'", create_album_name)
@@ -153,6 +156,8 @@ class ImmichActionExecutor(ActionExecutor):
else: else:
created = await self._client.create_album(create_album_name) created = await self._client.create_album(create_album_name)
target_album_ids = [created.get("id", "")] 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]) _LOGGER.info("Created album '%s' with id %s", create_album_name, target_album_ids[0])
if not target_album_ids: if not target_album_ids:
@@ -169,6 +174,7 @@ class ImmichActionExecutor(ActionExecutor):
for album_id in target_album_ids: for album_id in target_album_ids:
album_asset_ids: set[str] = set() album_asset_ids: set[str] = set()
needs_thumbnail = album_id in album_created_now
if album_id and album_id != "__dry_run_new__": if album_id and album_id != "__dry_run_new__":
album = await self._client.get_album(album_id) album = await self._client.get_album(album_id)
@@ -176,27 +182,56 @@ class ImmichActionExecutor(ActionExecutor):
if not dry_run: if not dry_run:
created = await self._client.create_album(create_album_name) created = await self._client.create_album(create_album_name)
album_id = created.get("id", album_id) 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) _LOGGER.info("Created album '%s' with id %s", create_album_name, album_id)
elif album is None: elif album is None:
album_details.append({"album_id": album_id, "error": "not found"}) album_details.append({"album_id": album_id, "error": "not found"})
continue continue
elif album is not None: elif album is not None:
album_asset_ids = set(album.asset_ids) 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] new_asset_ids = [aid for aid in candidate_ids if aid not in album_asset_ids]
skipped = len(candidate_ids) - len(new_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: if new_asset_ids and not dry_run and album_id:
for i in range(0, len(new_asset_ids), 500): for i in range(0, len(new_asset_ids), 500):
batch = new_asset_ids[i : i + 500] batch = new_asset_ids[i : i + 500]
await self._client.add_assets_to_album(album_id, batch) await self._client.add_assets_to_album(album_id, batch)
_LOGGER.info("Added %d assets to album %s", len(new_asset_ids), album_id) _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: elif dry_run and new_asset_ids:
_LOGGER.info("[DRY RUN] Would add %d assets to album %s", len(new_asset_ids), album_id) _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_affected += len(new_asset_ids)
total_skipped += skipped 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( return RuleResult(
rule_name=rule_name, rule_name=rule_name,
@@ -228,10 +263,16 @@ class ImmichActionExecutor(ActionExecutor):
async def _gather_candidates( async def _gather_candidates(
self, criteria: dict[str, Any] self, criteria: dict[str, Any]
) -> list[str]: ) -> tuple[list[str], dict[str, str]]:
"""Gather asset IDs matching the criteria (union of all sources).""" """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() seen: set[str] = set()
result: list[str] = [] result: list[str] = []
types_by_id: dict[str, str] = {}
# Source 1: Person assets # Source 1: Person assets
person_ids = criteria.get("person_ids", []) person_ids = criteria.get("person_ids", [])
@@ -243,6 +284,7 @@ class ImmichActionExecutor(ActionExecutor):
if self._matches_filters(asset, criteria): if self._matches_filters(asset, criteria):
seen.add(aid) seen.add(aid)
result.append(aid) result.append(aid)
types_by_id[aid] = asset.get("type", "") or ""
# Source 2: Smart search # Source 2: Smart search
query = criteria.get("query", "") query = criteria.get("query", "")
@@ -254,6 +296,7 @@ class ImmichActionExecutor(ActionExecutor):
if self._matches_filters(asset, criteria): if self._matches_filters(asset, criteria):
seen.add(aid) seen.add(aid)
result.append(aid) result.append(aid)
types_by_id[aid] = asset.get("type", "") or ""
# Exclude assets belonging to excluded persons # Exclude assets belonging to excluded persons
exclude_person_ids = criteria.get("exclude_person_ids", []) exclude_person_ids = criteria.get("exclude_person_ids", [])
@@ -266,8 +309,12 @@ class ImmichActionExecutor(ActionExecutor):
if aid: if aid:
excluded_asset_ids.add(aid) excluded_asset_ids.add(aid)
result = [aid for aid in result if aid not in excluded_asset_ids] 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( def _matches_filters(
self, asset: dict[str, Any], criteria: dict[str, Any] 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): except (ValueError, AttributeError):
created_at = datetime.now(timezone.utc) 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( return MediaAsset(
id=asset.id, id=asset.id,
type=media_type, type=media_type,
@@ -252,8 +262,8 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
description=asset.description or None, description=asset.description or None,
tags=list(asset.people), tags=list(asset.people),
thumbnail_url=f"{external_url}/api/assets/{asset.id}/thumbnail", thumbnail_url=f"{external_url}/api/assets/{asset.id}/thumbnail",
preview_url=f"{external_url}/api/assets/{asset.id}/thumbnail?size=preview", preview_url=preview_url,
full_url=f"{external_url}/api/assets/{asset.id}/original", full_url=full_url,
extra={ extra={
"owner_id": asset.owner_id, "owner_id": asset.owner_id,
"is_favorite": asset.is_favorite, "is_favorite": asset.is_favorite,
@@ -264,7 +274,11 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
"state": asset.state, "state": asset.state,
"country": asset.country, "country": asset.country,
"thumbhash": asset.thumbhash, "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, "file_size": asset.file_size,
"playback_size": None,
}, },
) )
@@ -235,8 +235,9 @@ class ImmichClient:
query: str, query: str,
album_ids: list[str] | None = None, album_ids: list[str] | None = None,
limit: int = 10, limit: int = 10,
page: int = 1,
) -> list[dict[str, Any]]: ) -> 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: if album_ids:
payload["albumIds"] = album_ids payload["albumIds"] = album_ids
try: try:
@@ -258,8 +259,9 @@ class ImmichClient:
query: str, query: str,
album_ids: list[str] | None = None, album_ids: list[str] | None = None,
limit: int = 10, limit: int = 10,
page: int = 1,
) -> list[dict[str, Any]]: ) -> 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: if album_ids:
payload["albumIds"] = album_ids payload["albumIds"] = album_ids
try: try:
@@ -279,14 +281,28 @@ class ImmichClient:
async def search_by_person( async def search_by_person(
self, person_id: str, limit: int = 10 self, person_id: str, limit: int = 10
) -> list[dict[str, Any]]: ) -> 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: try:
async with self._session.get( async with self._session.post(
f"{self._url}/api/people/{person_id}/assets", f"{self._url}/api/search/metadata",
headers=self._headers, headers=self._json_headers,
json=payload,
) as response: ) as response:
if response.status == 200: if response.status == 200:
data = await response.json() data = await response.json()
return data[:limit] if isinstance(data, list) else [] items = data.get("assets", {}).get("items", [])
return items[:limit]
except aiohttp.ClientError: except aiohttp.ClientError:
pass pass
return [] return []
@@ -329,7 +345,15 @@ class ImmichClient:
async def add_assets_to_album( async def add_assets_to_album(
self, album_id: str, asset_ids: list[str] self, album_id: str, asset_ids: list[str]
) -> dict[str, Any]: ) -> 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} payload = {"ids": asset_ids}
try: try:
async with self._session.put( async with self._session.put(
@@ -337,14 +361,51 @@ class ImmichClient:
headers=self._json_headers, headers=self._json_headers,
json=payload, json=payload,
) as response: ) as response:
if response.status == 200: body_text = await response.text()
return await response.json() 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( 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: except aiohttp.ClientError as err:
raise ImmichApiError(f"Error adding assets to album: {err}") from 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( async def remove_assets_from_album(
self, album_id: str, asset_ids: list[str] self, album_id: str, asset_ids: list[str]
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -386,22 +447,49 @@ class ImmichClient:
raise ImmichApiError(f"Error creating album: {err}") from err raise ImmichApiError(f"Error creating album: {err}") from err
async def get_person_assets_all(self, person_id: str) -> list[dict[str, Any]]: async def get_person_assets_all(self, person_id: str) -> list[dict[str, Any]]:
"""Fetch ALL assets for a person (no limit).""" """Fetch ALL assets tagged with a person (paginated, no soft cap).
try:
async with self._session.get( Uses ``POST /api/search/metadata`` with ``personIds``. The legacy
f"{self._url}/api/people/{person_id}/assets", ``GET /api/people/{id}/assets`` endpoint was removed from Immich
headers=self._headers, around v1.106 and returns 404 on current servers switching to
) as response: the search endpoint is the only way to get person-filtered assets
if response.status == 200: from modern Immich.
data = await response.json() """
return data if isinstance(data, list) else [] all_items: list[dict[str, Any]] = []
if response.status == 404: page = 1
return [] page_size = 100
raise ImmichApiError( max_pages = 1000 # hard cap to avoid runaway loops if server misbehaves
f"Failed to fetch person {person_id} assets: HTTP {response.status}" while page <= max_pages:
) payload: dict[str, Any] = {
except aiohttp.ClientError as err: "personIds": [person_id],
raise ImmichApiError(f"Error fetching person assets: {err}") from err "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( async def search_smart_all(
self, query: str, limit: int = 1000 self, query: str, limit: int = 1000
@@ -64,6 +64,8 @@ def build_template_context(
# Flatten extras into asset dict for template access # Flatten extras into asset dict for template access
asset_dict.update(asset.extra) asset_dict.update(asset.extra)
asset_dict.setdefault("oversized", False) asset_dict.setdefault("oversized", False)
asset_dict.setdefault("file_size", None)
asset_dict.setdefault("playback_size", None)
assets.append(asset_dict) assets.append(asset_dict)
# Enrich assets with per-asset public URLs if album has a public share link # 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"] = max_video_bytes # bytes or None
ctx["max_video_size_mb"] = max_video_bytes // (1024 * 1024) if max_video_bytes else 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 has_oversized = False
if max_video_bytes: if max_video_bytes:
for a in assets: for a in assets:
if a.get("type") == "VIDEO": if a.get("type") == "VIDEO":
fs = a.get("file_size") size = a.get("playback_size")
oversized = fs is not None and fs > max_video_bytes oversized = size is not None and size > max_video_bytes
a["oversized"] = oversized a["oversized"] = oversized
if oversized: if oversized:
has_oversized = True has_oversized = True
@@ -1,10 +1,13 @@
"""Configuration backup/restore API (admin only).""" """Configuration backup/restore API (admin only)."""
import asyncio
import json import json
import logging import logging
import os
import signal
from datetime import datetime, timezone 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 fastapi.responses import JSONResponse
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -16,10 +19,24 @@ from ..services.backup_schema import (
ALL_CATEGORIES, BackupCategory, BackupFile, ConflictMode, SecretsMode, ALL_CATEGORIES, BackupCategory, BackupFile, ConflictMode, SecretsMode,
) )
from ..services.backup_service import ( from ..services.backup_service import (
cleanup_old_backups, export_backup, import_backup, list_backup_files, cleanup_old_backups, export_backup, export_backup_to_file, import_backup,
validate_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__) _LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/backup", tags=["backup"]) router = APIRouter(prefix="/api/backup", tags=["backup"])
@@ -131,6 +148,188 @@ async def import_config(
return result.model_dump() 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 # Scheduled backup settings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -205,6 +404,37 @@ async def get_backup_files(
return list_backup_files(_backup_dir()) 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}") @router.get("/files/{filename}")
async def download_backup_file( async def download_backup_file(
filename: str, filename: str,
@@ -42,6 +42,11 @@ class CommandTrackerUpdate(BaseModel):
class ListenerCreate(BaseModel): class ListenerCreate(BaseModel):
listener_type: str listener_type: str
listener_id: int listener_id: int
allowed_album_ids: list[str] | None = None
class ListenerUpdate(BaseModel):
allowed_album_ids: list[str] | None = None
# --- Command Tracker CRUD --- # --- Command Tracker CRUD ---
@@ -299,6 +304,7 @@ async def add_listener(
command_tracker_id=tracker_id, command_tracker_id=tracker_id,
listener_type=body.listener_type, listener_type=body.listener_type,
listener_id=body.listener_id, listener_id=body.listener_id,
allowed_album_ids=body.allowed_album_ids,
) )
session.add(listener) session.add(listener)
await session.commit() await session.commit()
@@ -316,6 +322,30 @@ async def add_listener(
return await _listener_response(session, 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) @router.delete("/{tracker_id}/listeners/{listener_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_listener( async def remove_listener(
tracker_id: int, tracker_id: int,
@@ -394,6 +424,7 @@ async def _listener_response(session: AsyncSession, l: CommandTrackerListener) -
"command_tracker_id": l.command_tracker_id, "command_tracker_id": l.command_tracker_id,
"listener_type": l.listener_type, "listener_type": l.listener_type,
"listener_id": l.listener_id, "listener_id": l.listener_id,
"allowed_album_ids": l.allowed_album_ids,
"name": name, "name": name,
"created_at": l.created_at.isoformat(), "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: 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: if consumers:
detail = f"Cannot delete {entity_name}: used by {len(consumers)} consumer(s). " + "; ".join(consumers) summary = f"Cannot delete {entity_name}: used by {len(consumers)} consumer(s)."
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) 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]: async def check_service_provider(session: AsyncSession, provider_id: int) -> list[str]:
@@ -3,12 +3,14 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import delete as sa_delete
from sqlmodel import func, select from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user from ..auth.dependencies import get_current_user
from ..database.engine import get_session from ..database.engine import get_session
from ..database.models import ( from ..database.models import (
Action,
CommandConfig, CommandConfig,
CommandTemplateConfig, CommandTemplateConfig,
CommandTracker, CommandTracker,
@@ -54,12 +56,10 @@ async def get_status(
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id) select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
)).one() )).one()
# Build events query with filters # Build events query with filters. EventLog.user_id is the owner column;
events_query = ( # action events (event_type starts with "action_") have tracker_id NULL but
select(EventLog) # user_id set, so we filter by user_id directly.
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id) events_query = select(EventLog).where(EventLog.user_id == user.id)
.where(NotificationTracker.user_id == user.id)
)
if event_type: if event_type:
events_query = events_query.where(EventLog.event_type == 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( events_query = events_query.where(
EventLog.collection_name.contains(search) EventLog.collection_name.contains(search)
| EventLog.tracker_name.contains(search) | EventLog.tracker_name.contains(search)
| EventLog.action_name.contains(search)
| EventLog.provider_name.contains(search) | EventLog.provider_name.contains(search)
) )
@@ -84,6 +85,65 @@ async def get_status(
events_query = events_query.offset(offset).limit(limit) events_query = events_query.offset(offset).limit(limit)
recent_events = await session.exec(events_query) 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 { return {
"providers": providers_count, "providers": providers_count,
@@ -94,19 +154,43 @@ async def get_status(
{ {
"id": e.id, "id": e.id,
"event_type": e.event_type, "event_type": e.event_type,
"collection_name": e.collection_name, "collection_name": _display_subject(e),
"tracker_name": e.tracker_name or "", "tracker_name": _display_tracker_name(e),
"provider_name": e.provider_name or "", "action_id": e.action_id,
"action_name": _display_action_name(e),
"provider_name": _display_provider_name(e),
"provider_id": e.provider_id, "provider_id": e.provider_id,
"assets_count": e.assets_count or 0, "assets_count": e.assets_count or 0,
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""), "created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
"details": e.details or {}, "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") @router.get("/counts")
async def get_nav_counts( async def get_nav_counts(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
@@ -192,8 +276,7 @@ async def get_event_chart(
EventLog.event_type, EventLog.event_type,
func.count().label("total"), func.count().label("total"),
) )
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id) .where(EventLog.user_id == user.id, EventLog.created_at >= cutoff)
.where(NotificationTracker.user_id == user.id, EventLog.created_at >= cutoff)
) )
if event_type: if event_type:
@@ -204,6 +287,7 @@ async def get_event_chart(
query = query.where( query = query.where(
EventLog.collection_name.contains(search) EventLog.collection_name.contains(search)
| EventLog.tracker_name.contains(search) | EventLog.tracker_name.contains(search)
| EventLog.action_name.contains(search)
| EventLog.provider_name.contains(search) | EventLog.provider_name.contains(search)
) )
@@ -162,8 +162,9 @@ async def get_template_variables(
"city": "City name", "city": "City name",
"state": "State/region name", "state": "State/region name",
"country": "Country name", "country": "Country name",
"file_size": "File size in bytes (null if unknown)", "file_size": "Original asset size in bytes (null if unknown)",
"oversized": "Whether video exceeds the target's size limit (boolean, videos only)", "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)", "public_url": "Per-asset public share URL (empty if no album link)",
"url": "Public viewer URL (if shared)", "url": "Public viewer URL (if shared)",
"download_url": "Direct download 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} 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): class ResetPasswordRequest(BaseModel):
new_password: str new_password: str
@@ -147,6 +147,7 @@ async def _dispatch_webhook_event(
# Log event # Log event
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys} extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
session.add(EventLog( session.add(EventLog(
user_id=tracker.user_id,
tracker_id=tracker.id, tracker_id=tracker.id,
tracker_name=tracker.name, tracker_name=tracker.name,
provider_id=provider_id, provider_id=provider_id,
@@ -6,7 +6,10 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from ..database.models import CommandConfig, CommandTracker, ServiceProvider, TelegramBot from ..database.models import (
CommandConfig, CommandTracker, CommandTrackerListener,
ServiceProvider, TelegramBot,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -51,6 +54,9 @@ class ProviderCommandHandler(ABC):
bot: TelegramBot, bot: TelegramBot,
tracker: CommandTracker, tracker: CommandTracker,
config: CommandConfig, config: CommandConfig,
*,
listener: CommandTrackerListener | None = None,
page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
"""Handle a provider-specific command for a single tracker. """Handle a provider-specific command for a single tracker.
@@ -77,6 +77,9 @@ class GiteaCommandHandler(ProviderCommandHandler):
bot: TelegramBot, bot: TelegramBot,
tracker: CommandTracker, tracker: CommandTracker,
config: CommandConfig, config: CommandConfig,
*,
listener: Any = None,
page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd) fn = _TEXT_COMMANDS.get(cmd)
if fn is None: if fn is None:
@@ -95,7 +95,7 @@ def _render_cmd_template(
async def _resolve_command_context( async def _resolve_command_context(
bot: TelegramBot, bot: TelegramBot,
) -> tuple[ ) -> tuple[
list[tuple[CommandTracker, CommandConfig, ServiceProvider]], list[tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]],
dict[int, dict[str, dict[str, str]]], dict[int, dict[str, dict[str, str]]],
]: ]:
"""Resolve all enabled command trackers, configs, and providers for a bot. """Resolve all enabled command trackers, configs, and providers for a bot.
@@ -148,7 +148,7 @@ async def _resolve_command_context(
else: else:
providers_by_id = {} providers_by_id = {}
tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]] = [] tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]] = []
for listener in listeners: for listener in listeners:
tracker = trackers_by_id.get(listener.command_tracker_id) tracker = trackers_by_id.get(listener.command_tracker_id)
if not tracker or not tracker.enabled: if not tracker or not tracker.enabled:
@@ -159,12 +159,12 @@ async def _resolve_command_context(
provider = providers_by_id.get(tracker.provider_id) provider = providers_by_id.get(tracker.provider_id)
if not provider: if not provider:
continue continue
tuples.append((tracker, config, provider)) tuples.append((tracker, config, provider, listener))
# Load command template slots per config (not merged) # Load command template slots per config (not merged)
templates_by_config_id: dict[int, dict[str, dict[str, str]]] = {} templates_by_config_id: dict[int, dict[str, dict[str, str]]] = {}
seen_config_ids: set[int] = set() seen_config_ids: set[int] = set()
for _, config, _ in tuples: for _, config, _, _ in tuples:
cfg_id = config.command_template_config_id cfg_id = config.command_template_config_id
if cfg_id and cfg_id not in seen_config_ids: if cfg_id and cfg_id not in seen_config_ids:
seen_config_ids.add(cfg_id) seen_config_ids.add(cfg_id)
@@ -204,7 +204,7 @@ def _merge_all_templates(
def _merge_enabled_commands( def _merge_enabled_commands(
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider]], ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider, CommandTrackerListener]],
) -> tuple[list[str], dict[str, Any]]: ) -> tuple[list[str], dict[str, Any]]:
"""Merge enabled_commands (union) and rate_limits from all configs. """Merge enabled_commands (union) and rate_limits from all configs.
@@ -215,7 +215,7 @@ def _merge_enabled_commands(
enabled: set[str] = set() enabled: set[str] = set()
merged_limits: dict[str, int] = {} merged_limits: dict[str, int] = {}
for _, config, _ in ctx: for _, config, _, _ in ctx:
enabled.update(config.enabled_commands or []) enabled.update(config.enabled_commands or [])
for category, cooldown in (config.rate_limits or {}).items(): for category, cooldown in (config.rate_limits or {}).items():
if category not in merged_limits: if category not in merged_limits:
@@ -278,8 +278,16 @@ async def handle_command(
# Provider-specific dispatch — per-tracker # Provider-specific dispatch — per-tracker
from .dispatch import get_handler 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] = [] 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: if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
_LOGGER.warning( _LOGGER.warning(
"Truncated command responses at %d for bot %d cmd /%s", "Truncated command responses at %d for bot %d cmd /%s",
@@ -298,6 +306,7 @@ async def handle_command(
result = await handler.handle( result = await handler.handle(
cmd, args, count, locale, response_mode, cmd, args, count, locale, response_mode,
provider, tracker_templates, bot, tracker, config, provider, tracker_templates, bot, tracker, config,
listener=listener, page=page,
) )
if result is not None: if result is not None:
responses.append(result) responses.append(result)
@@ -6,7 +6,7 @@ import logging
from typing import Any from typing import Any
from ...database.models import ( from ...database.models import (
CommandConfig, CommandTracker, CommandConfig, CommandTracker, CommandTrackerListener,
ServiceProvider, TelegramBot, ServiceProvider, TelegramBot,
) )
from ...services import make_immich_provider from ...services import make_immich_provider
@@ -78,6 +78,9 @@ class ImmichCommandHandler(ProviderCommandHandler):
bot: TelegramBot, bot: TelegramBot,
tracker: CommandTracker, tracker: CommandTracker,
config: CommandConfig, config: CommandConfig,
*,
listener: CommandTrackerListener | None = None,
page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
if cmd == "status": if cmd == "status":
ctx = await _cmd_status(provider, locale) ctx = await _cmd_status(provider, locale)
@@ -96,6 +99,7 @@ class ImmichCommandHandler(ProviderCommandHandler):
return await _cmd_immich( return await _cmd_immich(
cmd, args, count, locale, response_mode, cmd, args, count, locale, response_mode,
provider, cmd_templates, provider, cmd_templates,
listener=listener, page=page,
) )
return None return None
@@ -104,13 +108,25 @@ async def _cmd_immich(
cmd: str, args: str, count: int, locale: str, cmd: str, args: str, count: int, locale: str,
response_mode: str, provider: ServiceProvider, response_mode: str, provider: ServiceProvider,
cmd_templates: dict[str, dict[str, str]], cmd_templates: dict[str, dict[str, str]],
*,
listener: CommandTrackerListener | None = None,
page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
"""Handle commands that need Immich API access and may return media.""" """Handle commands that need Immich API access and may return media."""
notification_trackers = await get_trackers_for_provider(provider.id) notification_trackers = await get_trackers_for_provider(provider.id)
all_album_ids: list[str] = [] all_album_ids: list[str] = []
seen: set[str] = set()
for t in notification_trackers: 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("/") 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 result: str | dict[str, Any] | None = None
if cmd == "search": 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": 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": elif cmd == "person":
result = await cmd_person(client, args, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls) result = await cmd_person(client, args, count, locale, response_mode, cmd_templates, asset_public_urls=asset_public_urls)
elif cmd == "place": elif cmd == "place":
@@ -25,11 +25,12 @@ async def cmd_search(
locale: str, response_mode: str, locale: str, response_mode: str,
cmd_templates: dict[str, dict[str, str]], cmd_templates: dict[str, dict[str, str]],
asset_public_urls: dict[str, str] | None = None, asset_public_urls: dict[str, str] | None = None,
page: int = 1,
) -> str | dict[str, Any]: ) -> str | dict[str, Any]:
"""Handle /search command.""" """Handle /search command."""
if not args: if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "search", "query": ""}) 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 {}) _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "search", args, locale, response_mode, client, cmd_templates) 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, locale: str, response_mode: str,
cmd_templates: dict[str, dict[str, str]], cmd_templates: dict[str, dict[str, str]],
asset_public_urls: dict[str, str] | None = None, asset_public_urls: dict[str, str] | None = None,
page: int = 1,
) -> str | dict[str, Any]: ) -> str | dict[str, Any]:
"""Handle /find command.""" """Handle /find command."""
if not args: if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "find", "query": ""}) 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 {}) _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "find", args, locale, response_mode, client, cmd_templates) return _format_assets(assets, "find", args, locale, response_mode, client, cmd_templates)
@@ -52,6 +52,9 @@ class NutCommandHandler(ProviderCommandHandler):
bot: TelegramBot, bot: TelegramBot,
tracker: CommandTracker, tracker: CommandTracker,
config: CommandConfig, config: CommandConfig,
*,
listener: Any = None,
page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd) fn = _TEXT_COMMANDS.get(cmd)
if fn is None: if fn is None:
@@ -69,6 +69,9 @@ class PlankaCommandHandler(ProviderCommandHandler):
bot: TelegramBot, bot: TelegramBot,
tracker: CommandTracker, tracker: CommandTracker,
config: CommandConfig, config: CommandConfig,
*,
listener: Any = None,
page: int = 1,
) -> CommandResponse | None: ) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd) fn = _TEXT_COMMANDS.get(cmd)
if fn is None: 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_id", "ALTER TABLE event_log ADD COLUMN provider_id INTEGER"),
("provider_name", "ALTER TABLE event_log ADD COLUMN provider_name TEXT DEFAULT ''"), ("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"), ("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): if not await _has_column(conn, "event_log", col):
await conn.execute(text(sql)) await conn.execute(text(sql))
logger.info("Added %s column to event_log table", col) 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 # Add commands_config to telegram_bot if missing
if await _has_table(conn, "telegram_bot"): if await _has_table(conn, "telegram_bot"):
if not await _has_column(conn, "telegram_bot", "commands_config"): 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") 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 # Add date_only_format to template_config if missing
if await _has_table(conn, "template_config"): if await _has_table(conn, "template_config"):
if not await _has_column(conn, "template_config", "date_only_format"): 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_type: str # e.g. "telegram_bot"
listener_id: int 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) created_at: datetime = Field(default_factory=_utcnow)
@@ -476,6 +481,10 @@ class EventLog(SQLModel, table=True):
__tablename__ = "event_log" __tablename__ = "event_log"
id: int | None = Field(default=None, primary_key=True) 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 # Python attr stays as tracker_id for backward compat; DB column is notification_tracker_id
tracker_id: int | None = Field( tracker_id: int | None = Field(
default=None, default=None,
@@ -484,6 +493,13 @@ class EventLog(SQLModel, table=True):
sa_column_kwargs={"name": "notification_tracker_id"}, sa_column_kwargs={"name": "notification_tracker_id"},
) )
tracker_name: str = Field(default="") 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_id: int | None = Field(default=None, index=True)
provider_name: str = Field(default="") provider_name: str = Field(default="")
event_type: str = Field(index=True) event_type: str = Field(index=True)
@@ -110,6 +110,10 @@ async def _seed_provider_command_template(
await session.flush() await session.flush()
else: else:
config = configs[0] 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"): for locale in ("en", "ru"):
slots = load_default_command_templates(locale, provider_type=provider_type) slots = load_default_command_templates(locale, provider_type=provider_type)
@@ -166,7 +170,7 @@ async def _seed_default_command_templates() -> None:
engine = get_engine() engine = get_engine()
async with AsyncSession(engine) as session: async with AsyncSession(engine) as session:
await _seed_provider_command_template( 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( await _seed_provider_command_template(
session, "gitea", "Default Gitea Commands", "Default Gitea command templates", session, "gitea", "Default Gitea Commands", "Default Gitea command templates",
@@ -242,7 +246,7 @@ async def _seed_default_tracking_configs() -> None:
"provider_type": "immich", "provider_type": "immich",
"name": "Default Immich", "name": "Default Immich",
"track_assets_added": True, "track_assets_added": True,
"track_assets_removed": True, "track_assets_removed": False,
"track_collection_renamed": True, "track_collection_renamed": True,
"track_collection_deleted": True, "track_collection_deleted": True,
"track_sharing_changed": False, "track_sharing_changed": False,
@@ -251,7 +255,7 @@ async def _seed_default_tracking_configs() -> None:
"provider_type": "google_photos", "provider_type": "google_photos",
"name": "Default Google Photos", "name": "Default Google Photos",
"track_assets_added": True, "track_assets_added": True,
"track_assets_removed": True, "track_assets_removed": False,
"track_collection_renamed": True, "track_collection_renamed": True,
"track_collection_deleted": True, "track_collection_deleted": True,
"track_sharing_changed": False, "track_sharing_changed": False,
@@ -66,6 +66,9 @@ async def lifespan(app: FastAPI):
await migrate_user_token_version(engine) await migrate_user_token_version(engine)
from .database.seeds import seed_all from .database.seeds import seed_all
await 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) # Configure webhook secret from DB setting (falls back to env var)
from sqlmodel.ext.asyncio.session import AsyncSession as _AS from sqlmodel.ext.asyncio.session import AsyncSession as _AS
from .api.app_settings import get_setting as _get_setting from .api.app_settings import get_setting as _get_setting
@@ -16,6 +16,7 @@ from ..database.models import (
Action, Action,
ActionExecution, ActionExecution,
ActionRule, ActionRule,
EventLog,
ServiceProvider, ServiceProvider,
) )
@@ -115,13 +116,42 @@ async def run_action(
execution.error = action_result.error or "" execution.error = action_result.error or ""
session.add(execution) 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: if not is_dry_run:
action = await session.get(Action, action_id) action = await session.get(Action, action_id)
if action: if action:
action.last_run_at = datetime.now(timezone.utc) action.last_run_at = datetime.now(timezone.utc)
action.last_run_status = execution.status if execution else "" action.last_run_status = execution.status if execution else ""
session.add(action) 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() 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: 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": if target.type == "broadcast":
return await _send_broadcast_test(target, locale) 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) message = _get_test_message(locale, target.type)
return await send_to_target(target, message) 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: async def _send_broadcast_test(target: NotificationTarget, locale: str) -> dict:
"""Send test notifications to all child targets of a broadcast target.""" """Send test notifications to all child targets of a broadcast target."""
child_ids = target.config.get("child_target_ids", []) 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", "public_url": "https://immich.example.com/share/abc123/photos/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"download_url": "https://immich.example.com/api/assets/abc123/original", "download_url": "https://immich.example.com/api/assets/abc123/original",
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail", "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, "oversized": False,
} }
@@ -43,7 +44,8 @@ _SAMPLE_VIDEO_ASSET = {
"photo_url": None, "photo_url": None,
"public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef", "public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef",
"playback_url": "https://immich.example.com/api/assets/def456/video", "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, "oversized": True,
} }
@@ -34,6 +34,9 @@ async def start_scheduler() -> None:
# Schedule daily cleanup of old event log entries # Schedule daily cleanup of old event log entries
_schedule_event_cleanup() _schedule_event_cleanup()
# Schedule periodic Telegram chat title refresh
_schedule_telegram_chat_sync()
# Start debounced command auto-sync scheduler # Start debounced command auto-sync scheduler
from .command_sync import start_sync_scheduler from .command_sync import start_sync_scheduler
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") _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: async def _cleanup_old_events() -> None:
"""Delete EventLog entries older than 90 days.""" """Delete EventLog entries older than 90 days."""
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -28,6 +28,16 @@ _LOGGER = logging.getLogger(__name__)
# Track last update_id per bot to use as offset # Track last update_id per bot to use as offset
_last_update_id: dict[int, int] = {} _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]: async def _get_bot_ids_with_active_listeners() -> set[int]:
"""Return bot IDs that have at least one active command tracker listener. """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) _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: async def _poll_bot(bot_id: int) -> None:
"""Fetch updates from Telegram and process them.""" """Fetch updates from Telegram and process them."""
engine = get_engine() engine = get_engine()
@@ -167,6 +235,15 @@ async def _poll_bot(bot_id: int) -> None:
offset=offset + 1 if offset else None, limit=50, offset=offset + 1 if offset else None, limit=50,
) )
if not result.get("success"): 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 return
updates = result.get("result", []) updates = result.get("result", [])
except Exception as e: except Exception as e:
@@ -79,7 +79,8 @@ async def dispatch_test_notification(
if locale_map: if locale_map:
template_slots = {EventType.SCHEDULED_MESSAGE.value: 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) resolved = await _resolve_target(session, target)
target_cfg = TargetConfig( target_cfg = TargetConfig(
@@ -95,21 +96,47 @@ async def dispatch_test_notification(
receivers=resolved["receivers"], 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 # Fetch assets and build event
event = await _build_event( try:
provider_type=provider.type, event = await _build_event(
provider_config=provider_config, provider_type=provider.type,
provider_name=provider.name or provider.type, provider_config=provider_config,
tracker_name=tracker.name or "", provider_name=provider.name or provider.type,
tracker_filters=dict(tracker.filters) if tracker.filters else {}, tracker_name=tracker.name or "",
collection_ids=collection_ids, tracker_filters=dict(tracker.filters) if tracker.filters else {},
test_type=test_type, collection_ids=collection_ids,
tracking_config=tracking_config, 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: 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"): 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 # Dispatch through the real NotificationDispatcher
url_cache, asset_cache = await _get_telegram_caches() url_cache, asset_cache = await _get_telegram_caches()
@@ -136,6 +163,13 @@ async def _build_event(
from datetime import datetime, timezone from datetime import datetime, timezone
if provider_type == "immich": 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( return await _build_immich_event(
provider_config=provider_config, provider_config=provider_config,
provider_name=provider_name, 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( async def _build_native_memory_event(
immich, immich,
ext_domain: str, ext_domain: str,
@@ -191,6 +191,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
for event in events: for event in events:
assets_count = event.added_count or event.removed_count or 0 assets_count = event.added_count or event.removed_count or 0
log = EventLog( log = EventLog(
user_id=tracker.user_id,
tracker_id=tracker_id, tracker_id=tracker_id,
tracker_name=tracker.name, tracker_name=tracker.name,
provider_id=provider.id, provider_id=provider.id,