feat: UX & notification improvements — icons, events, chat names, link validation, templates

- Show entity icons on all cards with fallback defaults (providers, trackers, targets, bots)
- Enrich EventLog with provider_name, tracker_name, assets_count; add DB migration
- Dashboard events: filtering (type, provider, search), sorting, pagination, dynamic page size
- Friendly chat names on telegram target cards (resolve from TelegramChat table)
- Test message button on bot chat items with locale-aware messages
- Album public link validation on tracker save with auto-create dialog
- Support albums without public links: conditional <a href> in templates
- Fetch shared links during poll, enrich events with public_url/protected_url
- Per-asset public_url in template context ({share_url}/photos/{asset_id})
- Common date/location detection: common_date + common_location context vars
- Dual date formats: date_format (datetime) + date_only_format (date only)
- Template clone button, HTML link rendering in template preview
- Fix Telegram asset download 401: pass x-api-key headers through client
- Fix provider external_url matching for API key scoping
- Fix event timestamp timezone (append Z suffix for UTC)
- Localize event filter controls, test messages (EN/RU)
- Template variable UI helpers updated with all new fields
- CLAUDE.md: template system sync rules documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 16:18:03 +03:00
parent 91e5cd58e9
commit 03c5c66eed
41 changed files with 1424 additions and 132 deletions
+9
View File
@@ -52,3 +52,12 @@ NotificationTarget → template_config_id, type: "telegram"/"webhook", config: J
- `user_id=0` on TemplateConfig = system default (EN/RU seeded on first startup)
- DB: SQLite + async SQLAlchemy via sqlmodel, auto-created on startup
- API: All CRUD routes under `/api/`, auth via JWT Bearer, `NOTIFY_BRIDGE_` env prefix
## Template System Sync Rules
**IMPORTANT**: When adding or changing template context variables, you MUST update ALL of these in sync:
1. **`packages/core/.../templates/context.py`** — `build_template_context()` where variables are computed
2. **`packages/server/.../api/template_configs.py`** — `_SAMPLE_CONTEXT` dict (for preview rendering)
3. **`packages/server/.../api/template_configs.py`** — `get_template_variables()` endpoint (`event_vars`, `asset_fields`, `album_fields`, `scheduled_vars`, per-slot variable dicts)
4. **`packages/core/.../templates/defaults/{en,ru}/*.jinja2`** — default template files using the new variables
5. **`packages/core/.../providers/immich/provider.py`** — `IMMICH_VARIABLES` list (provider-specific variable definitions)
+17 -2
View File
@@ -46,7 +46,20 @@
"assetsRemoved": "assets removed",
"collectionRenamed": "collection renamed",
"collectionDeleted": "collection deleted",
"sharingChanged": "sharing changed"
"sharingChanged": "sharing changed",
"searchEvents": "Search events...",
"allEvents": "All Events",
"filterAssetsAdded": "Assets Added",
"filterAssetsRemoved": "Assets Removed",
"filterRenamed": "Renamed",
"filterDeleted": "Deleted",
"filterSharingChanged": "Sharing Changed",
"allProviders": "All Providers",
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
"loadingEvents": "Loading events...",
"asset": "asset",
"assets": "assets"
},
"providers": {
"title": "Providers",
@@ -284,7 +297,8 @@
"moreMessage": "More message",
"peopleFormat": "People format",
"dateLocation": "Date & Location",
"dateFormat": "Date format",
"dateFormat": "Date & time format",
"dateOnlyFormat": "Date only format",
"commonDate": "Common date",
"uniqueDate": "Per-asset date",
"locationFormat": "Location format",
@@ -417,6 +431,7 @@
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"clone": "Clone",
"edit": "Edit",
"description": "Description",
"close": "Close",
+17 -2
View File
@@ -46,7 +46,20 @@
"assetsRemoved": "удалены файлы",
"collectionRenamed": "альбом переименован",
"collectionDeleted": "альбом удалён",
"sharingChanged": "изменение доступа"
"sharingChanged": "изменение доступа",
"searchEvents": "Поиск событий...",
"allEvents": "Все события",
"filterAssetsAdded": "Добавление файлов",
"filterAssetsRemoved": "Удаление файлов",
"filterRenamed": "Переименование",
"filterDeleted": "Удаление",
"filterSharingChanged": "Изменение доступа",
"allProviders": "Все провайдеры",
"newestFirst": "Сначала новые",
"oldestFirst": "Сначала старые",
"loadingEvents": "Загрузка событий...",
"asset": "файл",
"assets": "файлов"
},
"providers": {
"title": "Провайдеры",
@@ -284,7 +297,8 @@
"moreMessage": "Сообщение \"ещё\"",
"peopleFormat": "Формат людей",
"dateLocation": "Дата и место",
"dateFormat": "Формат даты",
"dateFormat": "Формат даты и времени",
"dateOnlyFormat": "Формат даты",
"commonDate": "Общая дата",
"uniqueDate": "Дата файла",
"locationFormat": "Формат места",
@@ -417,6 +431,7 @@
"save": "Сохранить",
"cancel": "Отмена",
"delete": "Удалить",
"clone": "Копировать",
"edit": "Редактировать",
"description": "Описание",
"close": "Закрыть",
+166 -5
View File
@@ -8,6 +8,7 @@
import MdiIcon from '$lib/components/MdiIcon.svelte';
let status = $state<any>(null);
let providers = $state<any[]>([]);
let loaded = $state(false);
let error = $state('');
@@ -16,6 +17,27 @@
let displayTotal = $state(0);
let displayTargets = $state(0);
// Event filters
let filterEventType = $state('');
let filterProviderId = $state('');
let filterSearch = $state('');
let filterSort = $state('newest');
let eventsLimit = $state(calcPageSize());
let eventsOffset = $state(0);
let eventsLoading = $state(false);
let currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1);
let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
/** Calculate how many event rows fit in the remaining viewport space. */
function calcPageSize(): number {
if (typeof window === 'undefined') return 8;
const EVENT_ROW_HEIGHT = 50; // px per event row (content + gap)
const FIXED_OVERHEAD = 390; // header + stats + events header + filters + paginator + padding
const available = window.innerHeight - FIXED_OVERHEAD;
return Math.max(3, Math.floor(available / EVENT_ROW_HEIGHT));
}
function animateCount(from: number, to: number, setter: (v: number) => void, duration = 600) {
if (to === 0) { setter(0); return; }
const start = performance.now();
@@ -29,9 +51,67 @@
requestAnimationFrame(frame);
}
onMount(async () => {
async function loadEvents() {
eventsLoading = true;
try {
status = await api<any>('/status');
const params = new URLSearchParams();
if (filterEventType) params.set('event_type', filterEventType);
if (filterProviderId) params.set('provider_id', filterProviderId);
if (filterSearch) params.set('search', filterSearch);
params.set('sort', filterSort);
params.set('limit', String(eventsLimit));
params.set('offset', String(eventsOffset));
const qs = params.toString();
status = await api<any>(`/status${qs ? '?' + qs : ''}`);
} catch (err: any) {
error = err.message || t('common.error');
} finally {
eventsLoading = false;
}
}
function applyFilters() {
eventsOffset = 0;
loadEvents();
}
function goToPage(page: number) {
eventsOffset = (page - 1) * eventsLimit;
loadEvents();
}
let searchTimeout: ReturnType<typeof setTimeout>;
function onSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(applyFilters, 300);
}
let resizeTimeout: ReturnType<typeof setTimeout>;
function onResize() {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
const newLimit = calcPageSize();
if (newLimit !== eventsLimit) {
eventsLimit = newLimit;
eventsOffset = 0;
loadEvents();
}
}, 200);
}
onMount(() => {
eventsLimit = calcPageSize();
window.addEventListener('resize', onResize);
loadInitial();
return () => window.removeEventListener('resize', onResize);
});
async function loadInitial() {
try {
[status, providers] = await Promise.all([
api<any>(`/status?limit=${eventsLimit}`),
api<any[]>('/providers'),
]);
setTimeout(() => {
animateCount(0, status.providers, (v) => displayProviders = v);
animateCount(0, status.trackers.active, (v) => displayActive = v);
@@ -43,7 +123,7 @@
} finally {
loaded = true;
}
});
}
const statCards = $derived(status ? [
{ icon: 'mdiServer', label: 'dashboard.providers', value: displayProviders, color: '#0d9488' },
@@ -77,6 +157,15 @@
assets_added: '#059669', assets_removed: '#ef4444',
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
};
const eventTypeOptions = $derived([
{ value: '', label: t('dashboard.allEvents') },
{ value: 'assets_added', label: t('dashboard.filterAssetsAdded') },
{ value: 'assets_removed', label: t('dashboard.filterAssetsRemoved') },
{ value: 'collection_renamed', label: t('dashboard.filterRenamed') },
{ value: 'collection_deleted', label: t('dashboard.filterDeleted') },
{ value: 'sharing_changed', label: t('dashboard.filterSharingChanged') },
]);
</script>
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
@@ -114,8 +203,41 @@
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
<MdiIcon name="mdiPulse" size={18} />
{t('dashboard.recentEvents')}
{#if status.total_events > 0}
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
{/if}
</h3>
{#if status.recent_events.length === 0}
<!-- Filters -->
<div class="flex flex-wrap items-center gap-2 mb-4">
<div class="flex-1 min-w-[150px] max-w-[260px]">
<input type="text" bind:value={filterSearch} oninput={onSearchInput}
placeholder={t('dashboard.searchEvents')}
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<select bind:value={filterEventType} onchange={applyFilters}
class="px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
{#each eventTypeOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<select bind:value={filterProviderId} onchange={applyFilters}
class="px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="">{t('dashboard.allProviders')}</option>
{#each providers as p}
<option value={p.id}>{p.name}</option>
{/each}
</select>
<select bind:value={filterSort} onchange={applyFilters}
class="px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="newest">{t('dashboard.newestFirst')}</option>
<option value="oldest">{t('dashboard.oldestFirst')}</option>
</select>
</div>
{#if eventsLoading}
<Card><p class="text-sm text-center py-4" style="color: var(--color-muted-foreground);">{t('dashboard.loadingEvents')}</p></Card>
{:else if status.recent_events.length === 0}
<Card>
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
<div style="opacity: 0.4;"><MdiIcon name="mdiCalendarBlank" size={40} /></div>
@@ -130,19 +252,58 @@
{#if i < status.recent_events.length - 1}<div class="event-line"></div>{/if}
<div class="event-content">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<div class="flex items-center gap-2 min-w-0 flex-wrap">
<span style="color: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; flex-shrink: 0;">
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={16} />
</span>
<span class="text-sm font-medium truncate">{event.collection_name}</span>
<span class="event-badge">{t(eventLabels[event.event_type] || event.event_type)}</span>
{#if event.assets_count > 0}
<span class="event-badge" style="background: {eventColors[event.event_type]}20; color: {eventColors[event.event_type]};">{event.assets_count} {event.assets_count === 1 ? t('dashboard.asset') : t('dashboard.assets')}</span>
{/if}
</div>
<span class="text-xs whitespace-nowrap font-mono" style="color: var(--color-muted-foreground);">{timeAgo(event.created_at)}</span>
</div>
{#if event.provider_name || event.tracker_name}
<div class="flex items-center gap-2 mt-1 text-xs" style="color: var(--color-muted-foreground);">
{#if event.provider_name}
<span class="flex items-center gap-1"><MdiIcon name="mdiServer" size={12} />{event.provider_name}</span>
{/if}
{#if event.tracker_name}
<span class="flex items-center gap-1"><MdiIcon name="mdiRadar" size={12} />{event.tracker_name}</span>
{/if}
</div>
{/if}
</div>
</div>
{/each}
</div>
<!-- Paginator -->
{#if totalPages > 1}
<div class="flex items-center justify-center gap-1 mt-4">
<button onclick={() => goToPage(currentPage - 1)} disabled={currentPage <= 1}
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-30 disabled:cursor-default">
<MdiIcon name="mdiChevronLeft" size={16} />
</button>
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
<button onclick={() => goToPage(page)}
class="px-2.5 py-1 text-xs font-mono rounded-md border transition-colors {page === currentPage
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)] border-[var(--color-primary)]'
: 'border-[var(--color-border)] hover:bg-[var(--color-muted)]'}">
{page}
</button>
{:else if page === currentPage - 2 || page === currentPage + 2}
<span class="px-1 text-xs text-[var(--color-muted-foreground)]">...</span>
{/if}
{/each}
<button onclick={() => goToPage(currentPage + 1)} disabled={currentPage >= totalPages}
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-30 disabled:cursor-default">
<MdiIcon name="mdiChevronRight" size={16} />
</button>
</div>
{/if}
{/if}
{/if}
+1 -3
View File
@@ -155,9 +155,7 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
{#if provider.icon}
<span style="color: var(--color-primary);"><MdiIcon name={provider.icon} size={20} /></span>
{/if}
<span style="color: var(--color-primary);"><MdiIcon name={provider.icon || 'mdiServer'} size={20} /></span>
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{provider.name}</p>
+8 -4
View File
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import { t, getLocale } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
@@ -94,7 +94,7 @@
}
async function test(id: number) {
try {
const res = await api(`/targets/${id}/test`, { method: 'POST' });
const res = await api(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
@@ -235,12 +235,16 @@
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
{#if target.icon}<MdiIcon name={target.icon} />{/if}
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || (target.type === 'telegram' ? 'mdiSend' : 'mdiWebhook')} size={20} /></span>
<p class="font-medium">{target.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{target.type === 'telegram' ? `Chat: ${target.config?.chat_id || '***'}` : target.config?.url || ''}
{#if target.type === 'telegram'}
Chat: {#if target.chat_name}{target.chat_name} <span class="font-mono text-xs">({target.config?.chat_id})</span>{:else}{target.config?.chat_id || '***'}{/if}
{:else}
{target.config?.url || ''}
{/if}
</p>
</div>
<div class="flex items-center gap-1">
+23 -3
View File
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import { t, getLocale } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
@@ -40,7 +40,7 @@
e.preventDefault(); error = ''; submitting = true;
try {
if (editing) {
await api(`/telegram-bots/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name }) });
await api(`/telegram-bots/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon }) });
snackSuccess(t('snack.botUpdated'));
} else {
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
@@ -94,12 +94,27 @@
} catch (err: any) { snackError(err.message); }
}
let chatTesting = $state<Record<string, boolean>>({});
function copyChatId(e: Event, chatId: string) {
e.stopPropagation();
navigator.clipboard.writeText(chatId);
snackInfo(`${t('snack.copied')}: ${chatId}`);
}
async function testChat(e: Event, botId: number, chatId: string) {
e.stopPropagation();
const key = `${botId}_${chatId}`;
if (chatTesting[key]) return;
chatTesting = { ...chatTesting, [key]: true };
try {
const res = await api(`/telegram-bots/${botId}/chats/${chatId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(res.error || 'Failed');
} catch (err: any) { snackError(err.message); }
chatTesting = { ...chatTesting, [key]: false };
}
function chatTypeLabel(type: string): string {
const map: Record<string, string> = {
private: t('telegramBot.private'),
@@ -161,7 +176,7 @@
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
{#if bot.icon}<MdiIcon name={bot.icon} />{/if}
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
{#if bot.bot_username}
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
@@ -198,9 +213,14 @@
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiSend" title="Test message" size={14}
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
</div>
</div>
{/each}
</div>
{/if}
@@ -42,7 +42,7 @@
const doValidate = async () => {
try {
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType }) });
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType, date_format: (form as any).date_format, date_only_format: (form as any).date_only_format }) });
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
@@ -66,7 +66,7 @@
for (const group of templateSlots) {
for (const slot of group.slots) {
const template = (form as any)[slot.key];
if (template && slot.key !== 'date_format') {
if (template && slot.key !== 'date_format' && slot.key !== 'date_only_format') {
validateSlot(slot.key, template, true);
}
}
@@ -84,6 +84,7 @@
scheduled_assets_message: '',
memory_mode_message: '',
date_format: '%d.%m.%Y, %H:%M UTC',
date_only_format: '%d.%m.%Y',
});
let form = $state(defaultForm());
let previewTargetType = $state('telegram');
@@ -103,6 +104,7 @@
]},
{ group: 'settings', slots: [
{ key: 'date_format', label: 'dateFormat', rows: 1 },
{ key: 'date_only_format', label: 'dateOnlyFormat', rows: 1 },
]},
];
@@ -134,6 +136,33 @@
} catch (err: any) { error = err.message; snackError(err.message); }
}
function clone(c: any) {
form = { ...defaultForm(), ...c, name: `${c.name} (Copy)`, description: c.description || '' };
delete (form as any).id;
delete (form as any).user_id;
delete (form as any).created_at;
editing = null;
showForm = true;
slotPreview = {};
slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100);
}
function sanitizePreview(html: string): string {
// Allow only Telegram-safe HTML tags, escape everything else
return html
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Restore allowed tags
.replace(/&lt;a href="([^"]*)"&gt;/g, '<a href="$1" target="_blank" rel="noopener">')
.replace(/&lt;\/a&gt;/g, '</a>')
.replace(/&lt;b&gt;/g, '<b>').replace(/&lt;\/b&gt;/g, '</b>')
.replace(/&lt;i&gt;/g, '<i>').replace(/&lt;\/i&gt;/g, '</i>')
.replace(/&lt;code&gt;/g, '<code>').replace(/&lt;\/code&gt;/g, '</code>')
.replace(/&lt;pre&gt;/g, '<pre>').replace(/&lt;\/pre&gt;/g, '</pre>');
}
function remove(id: number) {
confirmDelete = {
id,
@@ -198,8 +227,9 @@
{/if}
</div>
</div>
{#if slot.key === 'date_format'}
{#if slot.key === 'date_format' || slot.key === 'date_only_format'}
<input bind:value={(form as any)[slot.key]}
oninput={() => { clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); }}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
{:else}
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v: string) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} />
@@ -211,8 +241,8 @@
{/if}
{/if}
{#if slotPreview[slot.key] && !slotErrors[slot.key]}
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm">
<pre class="whitespace-pre-wrap text-xs">{slotPreview[slot.key]}</pre>
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm preview-html">
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.key])}</pre>
</div>
{/if}
{/if}
@@ -244,7 +274,7 @@
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
{#if config.icon}<MdiIcon name={config.icon} />{/if}
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
<p class="font-medium">{config.name}</p>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">System</span>
@@ -255,6 +285,7 @@
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
</div>
@@ -306,3 +337,13 @@
{/if}
{/if}
</Modal>
<style>
:global(.preview-html a) {
color: var(--color-primary);
text-decoration: underline;
}
:global(.preview-html a:hover) {
opacity: 0.8;
}
</style>
+129 -19
View File
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import { t, getLocale } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
@@ -29,7 +29,12 @@
let toggling = $state<Record<number, boolean>>({});
// Per tracker-target test state (keyed by `${ttId}_${testType}`)
let ttTesting = $state<Record<string, string>>({});
let ttFeedback = $state<Record<string, string>>({});
// Shared link validation
let linkWarning = $state<{ albums: any[], providerId: number } | null>(null);
let linkCheckLoading = $state(false);
let linkCreating = $state(false);
let previousCollectionIds = $state<string[]>([]);
// Tracker form
const defaultForm = () => ({
@@ -65,13 +70,14 @@
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch { collections = []; }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; }
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; previousCollectionIds = []; }
async function edit(trk: any) {
form = {
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
collection_ids: [...(trk.collection_ids || [])],
scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0,
};
previousCollectionIds = [...(trk.collection_ids || [])];
editing = trk.id; showForm = true;
if (form.provider_id) await loadCollections();
}
@@ -79,6 +85,41 @@
async function save(e: SubmitEvent) {
e.preventDefault(); error = '';
if (submitting) return;
// Check shared links for newly added albums
const newAlbumIds = form.collection_ids.filter(id => !previousCollectionIds.includes(id));
if (newAlbumIds.length > 0 && form.provider_id) {
linkCheckLoading = true;
try {
const missingAlbums: any[] = [];
for (const albumId of newAlbumIds) {
const links = await api(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
const validLink = (links as any[]).find((l: any) => l.is_accessible && !l.is_expired);
if (!validLink) {
const album = collections.find(c => c.id === albumId);
const problematicLink = (links as any[]).find((l: any) => l.is_expired || l.has_password);
missingAlbums.push({
id: albumId,
name: album?.albumName || album?.name || albumId,
issue: problematicLink
? (problematicLink.is_expired ? 'expired' : 'password-protected')
: 'missing',
});
}
}
if (missingAlbums.length > 0) {
linkWarning = { albums: missingAlbums, providerId: form.provider_id };
linkCheckLoading = false;
return; // Show warning, don't save yet
}
} catch { /* Proceed if check fails */ }
linkCheckLoading = false;
}
await doSave();
}
async function doSave() {
submitting = true;
try {
if (editing) {
@@ -88,9 +129,34 @@
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
snackSuccess(t('snack.trackerCreated'));
}
showForm = false; editing = null; await load();
showForm = false; editing = null; linkWarning = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); } finally { submitting = false; }
}
async function autoCreateLinks() {
if (!linkWarning) return;
linkCreating = true;
let created = 0;
for (const album of linkWarning.albums) {
if (album.issue === 'missing') {
try {
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, { method: 'POST' });
created++;
} catch (err: any) {
snackError(`Failed to create link for "${album.name}": ${err.message}`);
}
}
}
if (created > 0) snackSuccess(`Created ${created} public link(s)`);
linkWarning = null;
linkCreating = false;
await doSave();
}
function dismissLinkWarning() {
linkWarning = null;
doSave();
}
async function toggle(tracker: any) {
if (toggling[tracker.id]) return;
toggling = { ...toggling, [tracker.id]: true };
@@ -114,22 +180,26 @@
const key = `${ttId}_${testType}`;
if (ttTesting[key]) return;
ttTesting = { ...ttTesting, [key]: testType };
ttFeedback = { ...ttFeedback, [key]: '' };
try {
const endpoint = testType === 'basic' ? 'test' : `test-${testType}`;
await api(`/trackers/${trackerId}/targets/${ttId}/${endpoint}`, { method: 'POST' });
ttFeedback = { ...ttFeedback, [key]: 'ok' };
await api(`/trackers/${trackerId}/targets/${ttId}/${endpoint}?locale=${getLocale()}`, { method: 'POST' });
snackSuccess(t('snack.targetTestSent'));
} catch (err: any) {
ttFeedback = { ...ttFeedback, [key]: 'error' };
snackError(err.message);
} finally {
ttTesting = { ...ttTesting, [key]: '' };
setTimeout(() => { ttFeedback = { ...ttFeedback, [key]: '' }; }, 3000);
}
}
function toggleCollection(collectionId: string) { form.collection_ids = form.collection_ids.includes(collectionId) ? form.collection_ids.filter(id => id !== collectionId) : [...form.collection_ids, collectionId]; }
function formatDate(dateStr: string): string {
if (!dateStr) return '';
try {
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} catch { return ''; }
}
// --- Linked Targets Management ---
function toggleExpand(trackerId: number) {
if (expandedTracker === trackerId) { expandedTracker = null; return; }
@@ -227,6 +297,9 @@
<input type="checkbox" checked={form.collection_ids.includes(col.id)} onchange={() => toggleCollection(col.id)} />
{col.albumName || col.name} <span class="text-[var(--color-muted-foreground)]">({col.assetCount ?? col.asset_count ?? 0})</span>
</span>
{#if col.updatedAt || col.updated_at}
<span class="text-xs text-[var(--color-muted-foreground)] whitespace-nowrap ml-2">{formatDate(col.updatedAt || col.updated_at)}</span>
{/if}
</label>
{/each}
</div>
@@ -243,7 +316,9 @@
</div>
</div>
<button type="submit" disabled={submitting} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">{editing ? t('common.save') : t('trackers.createTracker')}</button>
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
{#if linkCheckLoading}Checking links...{:else}{editing ? t('common.save') : t('trackers.createTracker')}{/if}
</button>
</form>
</Card>
</div>
@@ -264,7 +339,7 @@
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
{#if tracker.icon}<MdiIcon name={tracker.icon} />{/if}
<span style="color: var(--color-primary);"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
<p class="font-medium">{tracker.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
@@ -295,7 +370,7 @@
{#each tracker.tracker_targets as tt}
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
<div class="flex items-center gap-2">
{#if tt.target_icon}<MdiIcon name={tt.target_icon} size={16} />{/if}
<span style="color: var(--color-primary);"><MdiIcon name={tt.target_icon || (tt.target_type === 'telegram' ? 'mdiSend' : 'mdiWebhook')} size={16} /></span>
<span class="font-medium">{tt.target_name || `Target #${tt.target_id}`}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
{#if !tt.enabled}
@@ -324,13 +399,6 @@
<IconButton icon="mdiHistory" size={14} title={t('trackingConfig.memoryMode')}
onclick={() => testTrackerTarget(tracker.id, tt.id, 'memory')}
disabled={!!ttTesting[`${tt.id}_memory`]} />
{#each ['basic', 'periodic', 'memory'] as testType}
{#if ttFeedback[`${tt.id}_${testType}`]}
<span class="text-xs {ttFeedback[`${tt.id}_${testType}`] === 'ok' ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">
{ttFeedback[`${tt.id}_${testType}`] === 'ok' ? '\u2713' : '\u2717'}
</span>
{/if}
{/each}
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
title={tt.enabled ? t('trackers.pause') : t('trackers.resume')}
onclick={() => updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} />
@@ -374,6 +442,48 @@
{/if}
{/if}
{#if linkWarning}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998; background:rgba(0,0,0,0.5);"
onclick={() => { linkWarning = null; }}
onkeydown={(e) => { if (e.key === 'Escape') linkWarning = null; }}>
</div>
<div style="position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:9999; width:28rem; max-width:90vw; background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; padding:1.5rem; box-shadow:0 20px 60px rgba(0,0,0,0.4);">
<div class="flex items-center gap-2 mb-3">
<span style="color: var(--color-warning-fg);"><MdiIcon name="mdiAlertCircle" size={22} /></span>
<h3 class="font-semibold">Albums Missing Public Links</h3>
</div>
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
The following albums don't have valid public shared links. Without public links, notification messages won't include clickable URLs to albums or assets.
</p>
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto">
{#each linkWarning.albums as album}
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
<span class="font-medium">{album.name}</span>
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{album.issue === 'expired' ? 'Expired' : album.issue === 'password-protected' ? 'Password Protected' : 'No Link'}
</span>
</div>
{/each}
</div>
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiInformation" size={14} /> Public links allow anyone with the URL to view album contents. Albums without links will still be tracked and assets sent to chats, but messages won't include clickable links.
</p>
<div class="flex items-center gap-2 justify-end">
<button onclick={dismissLinkWarning}
class="px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
Save without links
</button>
{#if linkWarning.albums.some(a => a.issue === 'missing')}
<button onclick={autoCreateLinks} disabled={linkCreating}
class="px-3 py-1.5 text-sm bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
{linkCreating ? 'Creating...' : `Create ${linkWarning.albums.filter(a => a.issue === 'missing').length} link(s)`}
</button>
{/if}
</div>
</div>
{/if}
<ConfirmModal
open={!!confirmDelete}
message={t('trackers.confirmDelete')}
@@ -18,7 +18,9 @@ from .webhook.client import WebhookClient
_LOGGER = logging.getLogger(__name__)
DEFAULT_TEMPLATE = (
'{{ added_count }} new item(s) added to "{{ collection_name }}".'
'{{ added_count }} new item(s) added to '
'{% if public_url %}<a href="{{ public_url }}">{{ collection_name }}</a>'
'{% else %}"{{ collection_name }}"{% endif %}.'
'{% if people %}\nPeople: {{ people | join(", ") }}{% endif %}'
)
@@ -30,8 +32,11 @@ class TargetConfig:
type: str # "telegram" or "webhook"
config: dict[str, Any] # type-specific config
template_slots: dict[str, str] | None = None # event_type -> template string
date_format: str = "%d.%m.%Y, %H:%M UTC"
date_only_format: str = "%d.%m.%Y"
provider_api_key: str | None = None # API key for downloading assets from provider
provider_internal_url: str | None = None # Internal provider URL for API key scoping
provider_external_url: str | None = None # External domain for API key scoping
class NotificationDispatcher:
@@ -68,7 +73,11 @@ class NotificationDispatcher:
template_str = slot
# Build context and render
ctx = build_template_context(event, target_type=target.type)
ctx = build_template_context(
event, target_type=target.type,
date_format=target.date_format,
date_only_format=target.date_only_format,
)
message = render_template(template_str, ctx)
if target.type == "telegram":
@@ -90,16 +99,19 @@ class NotificationDispatcher:
client = TelegramClient(session, bot_token)
# Build asset list for media sending
# Only attach API key header for URLs pointing to the internal provider
internal_url = target.provider_internal_url or ""
# Attach API key header for URLs pointing to the provider (internal or external)
provider_urls = []
if target.provider_internal_url:
provider_urls.append(target.provider_internal_url)
if target.provider_external_url:
provider_urls.append(target.provider_external_url)
assets = []
for asset in event.added_assets:
url = asset.full_url or asset.thumbnail_url
if url:
asset_type = "video" if asset.type.value == "video" else "photo"
# Include API key only for internal provider URLs
asset_headers = {}
if target.provider_api_key and internal_url and url.startswith(internal_url):
if target.provider_api_key and any(url.startswith(u) for u in provider_urls):
asset_headers["x-api-key"] = target.provider_api_key
assets.append({"url": url, "type": asset_type, "headers": asset_headers})
@@ -103,12 +103,14 @@ class TelegramClient:
chat_id, assets[0].get("url"), caption, reply_to_message_id,
parse_mode, max_asset_data_size, send_large_photos_as_documents,
assets[0].get("content_type"), assets[0].get("cache_key"),
download_headers=assets[0].get("headers"),
)
if len(assets) == 1 and assets[0].get("type") == "video":
return await self._send_video(
chat_id, assets[0].get("url"), caption, reply_to_message_id,
parse_mode, max_asset_data_size,
assets[0].get("content_type"), assets[0].get("cache_key"),
download_headers=assets[0].get("headers"),
)
if len(assets) == 1 and assets[0].get("type", "document") == "document":
url = assets[0].get("url")
@@ -116,7 +118,8 @@ class TelegramClient:
return {"success": False, "error": "Missing 'url' for document"}
try:
download_url = self._resolve_url(url)
async with self._session.get(download_url) as resp:
dl_headers = assets[0].get("headers") or {}
async with self._session.get(download_url, headers=dl_headers) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
data = await resp.read()
@@ -196,6 +199,7 @@ class TelegramClient:
reply_to_message_id: int | None = None, parse_mode: str = "HTML",
max_asset_data_size: int | None = None, send_large_photos_as_documents: bool = False,
content_type: str | None = None, cache_key: str | None = None,
download_headers: dict[str, str] | None = None,
) -> NotificationResult:
if not content_type:
content_type = "image/jpeg"
@@ -223,7 +227,7 @@ class TelegramClient:
try:
download_url = self._resolve_url(url)
async with self._session.get(download_url) as resp:
async with self._session.get(download_url, headers=download_headers or {}) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download photo: HTTP {resp.status}"}
data = await resp.read()
@@ -264,7 +268,7 @@ class TelegramClient:
self, chat_id: str, url: str | None, caption: str | None = None,
reply_to_message_id: int | None = None, parse_mode: str = "HTML",
max_asset_data_size: int | None = None, content_type: str | None = None,
cache_key: str | None = None,
cache_key: str | None = None, download_headers: dict[str, str] | None = None,
) -> NotificationResult:
if not content_type:
content_type = "video/mp4"
@@ -291,7 +295,7 @@ class TelegramClient:
try:
download_url = self._resolve_url(url)
async with self._session.get(download_url) as resp:
async with self._session.get(download_url, headers=download_headers or {}) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download video: HTTP {resp.status}"}
data = await resp.read()
@@ -396,9 +400,9 @@ class TelegramClient:
chunk_caption = caption if chunk_idx == 0 else None
chunk_reply = reply_to_message_id if chunk_idx == 0 else None
if item.get("type") == "photo":
result = await self._send_photo(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, send_large_photos_as_documents, item.get("content_type"), item.get("cache_key"))
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"))
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"))
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"))
else:
continue
if not result.get("success"):
@@ -435,7 +439,8 @@ class TelegramClient:
else:
try:
download_url = self._resolve_url(url)
async with self._session.get(download_url) as resp:
dl_headers = item.get("headers") or {}
async with self._session.get(download_url, headers=dl_headers) as resp:
if resp.status != 200:
continue
data = await resp.read()
@@ -184,6 +184,25 @@ class ImmichServiceProvider(ServiceProvider):
)
if event:
# Fetch shared links to enrich event with public_url
shared_links = await self._client.get_shared_links(album_id)
public_link = None
protected_link = None
for link in shared_links:
if link.is_accessible and not link.is_expired:
if link.has_password:
protected_link = link
else:
public_link = link
break # prefer non-password link
ext_domain = self._external_domain or self._client.external_url
if public_link:
event.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
elif protected_link:
event.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
# If no links, public_url stays absent — templates handle gracefully
events.append(event)
# Update state
@@ -226,6 +245,7 @@ class ImmichServiceProvider(ServiceProvider):
"id": a.get("id", ""),
"name": a.get("albumName", "Unnamed"),
"asset_count": a.get("assetCount", 0),
"updated_at": a.get("updatedAt", ""),
}
for a in albums
]
@@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from notify_bridge_core.models.events import ServiceEvent
@@ -10,6 +11,8 @@ from notify_bridge_core.models.events import ServiceEvent
def build_template_context(
event: ServiceEvent,
target_type: str = "webhook",
date_format: str = "%d.%m.%Y, %H:%M UTC",
date_only_format: str = "%d.%m.%Y",
) -> dict[str, Any]:
"""Build a flat template context dict from a ServiceEvent.
@@ -56,6 +59,15 @@ def build_template_context(
asset_dict.update(asset.extra)
assets.append(asset_dict)
# Enrich assets with per-asset public URLs if album has a public share link
album_public_url = event.extra.get("public_url", "")
if album_public_url:
for asset_dict in assets:
asset_dict["public_url"] = f"{album_public_url}/photos/{asset_dict['id']}"
else:
for asset_dict in assets:
asset_dict.setdefault("public_url", "")
ctx["assets"] = assets
ctx["added_assets"] = assets # alias for backward compat
@@ -63,9 +75,48 @@ def build_template_context(
ctx["has_videos"] = any(a.get("type") == "VIDEO" for a in assets)
ctx["has_photos"] = any(a.get("type") == "IMAGE" for a in assets)
# Date format strings (available to templates for custom formatting)
ctx["date_format"] = date_format
ctx["date_only_format"] = date_only_format
# Common date/location — set when ALL assets share the same value
ctx["common_date"] = ""
ctx["common_location"] = ""
if len(assets) > 1:
# Date: compare date portion only (YYYY-MM-DD)
dates = set()
for a in assets:
ca = a.get("created_at", "")
if ca:
dates.add(ca[:10]) # "2026-03-19T..." -> "2026-03-19"
if len(dates) == 1:
raw_date = dates.pop()
try:
ctx["common_date"] = datetime.fromisoformat(raw_date).strftime(date_only_format)
except (ValueError, TypeError):
ctx["common_date"] = raw_date
# Location: "City, Country" or just "City"
locations = set()
for a in assets:
city = a.get("city", "")
country = a.get("country", "")
if city:
loc = f"{city}, {country}" if country else city
locations.add(loc)
else:
locations.add("") # asset with no location breaks commonality
if len(locations) == 1 and "" not in locations:
ctx["common_location"] = locations.pop()
# Provider-specific extras merged at top level
ctx.update(event.extra)
# Ensure URL variables always exist (avoid Jinja2 undefined errors)
ctx.setdefault("public_url", "")
ctx.setdefault("protected_url", "")
ctx.setdefault("album_url", "")
# Provider-specific aliases for Immich
if event.provider_type.value == "immich":
ctx.setdefault("album_name", event.collection_name)
@@ -1,15 +1,17 @@
📷 {{ added_count }} new photo(s) added to album "{{ album_name }}".
📷 {{ added_count }} new photo(s) added to album {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
{%- if common_date %} 📅 {{ common_date }}{% endif %}
{%- if common_location %} 📍 {{ common_location }}{% endif %}
{%- if people %}
👤 {{ people | join(", ") }}
{%- endif %}
{%- if added_assets %}
{%- for asset in added_assets %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
{%- if not common_location and asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
{%- endif %}
{%- if target_type == "telegram" and has_videos %}
⚠️ Videos may not be sent due to Telegram's 50 MB file size limit.
{%- endif %}
{%- endif %}
@@ -1 +1 @@
🗑️ {{ removed_count }} photo(s) removed from album "{{ album_name }}".
🗑️ {{ removed_count }} photo(s) removed from album {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
@@ -1 +1 @@
✏️ Album "{{ old_name }}" renamed to "{{ new_name }}".
✏️ Album "{{ old_name }}" renamed to {% if public_url %}<a href="{{ public_url }}">{{ new_name }}</a>{% else %}"{{ new_name }}"{% endif %}.
@@ -1 +1 @@
🔗 Sharing changed for album "{{ album_name }}".
🔗 Sharing changed for album {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
@@ -1,15 +1,17 @@
📷 {{ added_count }} новых фото добавлено в альбом "{{ album_name }}".
📷 {{ added_count }} новых фото добавлено в альбом {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
{%- if common_date %} 📅 {{ common_date }}{% endif %}
{%- if common_location %} 📍 {{ common_location }}{% endif %}
{%- if people %}
👤 {{ people | join(", ") }}
{%- endif %}
{%- if added_assets %}
{%- for asset in added_assets %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {{ asset.filename }}
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
{%- if not common_location and asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
{%- if asset.is_favorite %} ❤️{% endif %}
{%- endfor %}
{%- endif %}
{%- if target_type == "telegram" and has_videos %}
⚠️ Видео может не отправиться из-за ограничения Telegram в 50 МБ.
{%- endif %}
{%- endif %}
@@ -1 +1 @@
🗑️ {{ removed_count }} фото удалено из альбома "{{ album_name }}".
🗑️ {{ removed_count }} фото удалено из альбома {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
@@ -1 +1 @@
✏️ Альбом "{{ old_name }}" переименован в "{{ new_name }}".
✏️ Альбом "{{ old_name }}" переименован в {% if public_url %}<a href="{{ public_url }}">{{ new_name }}</a>{% else %}"{{ new_name }}"{% endif %}.
@@ -1 +1 @@
🔗 Изменён доступ к альбому "{{ album_name }}".
🔗 Изменён доступ к альбому {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}.
@@ -216,6 +216,73 @@ async def list_collections(
return []
@router.get("/{provider_id}/albums/{album_id}/shared-links")
async def get_album_shared_links(
provider_id: int,
album_id: str,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Check shared links for a specific album."""
provider = await _get_user_provider(session, provider_id, user.id)
if provider.type == "immich":
from notify_bridge_core.providers.immich import ImmichServiceProvider
config = provider.config
async with aiohttp.ClientSession() as http_session:
immich = ImmichServiceProvider(
http_session,
config.get("url", ""),
config.get("api_key", ""),
config.get("external_domain"),
provider.name,
)
links = await immich.client.get_shared_links(album_id)
return [
{
"id": link.id,
"key": link.key,
"has_password": link.has_password,
"is_expired": link.is_expired,
"is_accessible": link.is_accessible,
}
for link in links
]
return []
@router.post("/{provider_id}/albums/{album_id}/shared-links")
async def create_album_shared_link(
provider_id: int,
album_id: str,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Auto-create a public shared link for an album."""
provider = await _get_user_provider(session, provider_id, user.id)
if provider.type == "immich":
from notify_bridge_core.providers.immich import ImmichServiceProvider
config = provider.config
async with aiohttp.ClientSession() as http_session:
immich = ImmichServiceProvider(
http_session,
config.get("url", ""),
config.get("api_key", ""),
config.get("external_domain"),
provider.name,
)
success = await immich.client.create_shared_link(album_id)
if success:
return {"success": True}
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="Failed to create shared link")
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="Provider type does not support shared links")
def _provider_response(p: ServiceProvider) -> dict:
"""Build a safe response dict for a provider."""
config = dict(p.config)
@@ -1,6 +1,6 @@
"""Status/dashboard API route."""
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -15,8 +15,15 @@ router = APIRouter(prefix="/api/status", tags=["status"])
async def get_status(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
# Event filtering
event_type: str | None = Query(None),
provider_id: int | None = Query(None),
search: str | None = Query(None),
sort: str = Query("newest"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""Get dashboard status data."""
"""Get dashboard status data with enriched events."""
providers_count = (await session.exec(
select(func.count()).select_from(ServiceProvider).where(ServiceProvider.user_id == user.id)
)).one()
@@ -31,24 +38,53 @@ async def get_status(
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
)).one()
recent_events = await session.exec(
# Build events query with filters
events_query = (
select(EventLog)
.join(Tracker, EventLog.tracker_id == Tracker.id)
.where(Tracker.user_id == user.id)
.order_by(EventLog.created_at.desc())
.limit(10)
)
if event_type:
events_query = events_query.where(EventLog.event_type == event_type)
if provider_id is not None:
events_query = events_query.where(EventLog.provider_id == provider_id)
if search:
events_query = events_query.where(
EventLog.collection_name.contains(search)
| EventLog.tracker_name.contains(search)
| EventLog.provider_name.contains(search)
)
# Count total matching events (for pagination)
count_query = select(func.count()).select_from(events_query.subquery())
total_events = (await session.exec(count_query)).one()
# Sort
if sort == "oldest":
events_query = events_query.order_by(EventLog.created_at.asc())
else:
events_query = events_query.order_by(EventLog.created_at.desc())
events_query = events_query.offset(offset).limit(limit)
recent_events = await session.exec(events_query)
return {
"providers": providers_count,
"trackers": {"total": len(trackers), "active": active_count},
"targets": targets_count,
"total_events": total_events,
"recent_events": [
{
"id": e.id,
"event_type": e.event_type,
"collection_name": e.collection_name,
"created_at": e.created_at.isoformat(),
"tracker_name": e.tracker_name or "",
"provider_name": e.provider_name or "",
"provider_id": e.provider_id,
"assets_count": e.assets_count or 0,
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
"details": e.details or {},
}
for e in recent_events.all()
],
@@ -1,6 +1,6 @@
"""Notification target management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -8,7 +8,7 @@ from typing import Any
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, User
from ..database.models import NotificationTarget, TelegramBot, TelegramChat, TrackerTarget, User
router = APIRouter(prefix="/api/targets", tags=["targets"])
@@ -18,14 +18,12 @@ class TargetCreate(BaseModel):
name: str
icon: str = ""
config: dict[str, Any] = {}
template_config_id: int | None = None
class TargetUpdate(BaseModel):
name: str | None = None
icon: str | None = None
config: dict[str, Any] | None = None
template_config_id: int | None = None
@router.get("")
@@ -37,18 +35,26 @@ async def list_targets(
result = await session.exec(
select(NotificationTarget).where(NotificationTarget.user_id == user.id)
)
return [
{
"id": t.id,
"type": t.type,
"name": t.name,
"icon": t.icon,
"config": _safe_config(t),
"template_config_id": t.template_config_id,
"created_at": t.created_at.isoformat(),
}
for t in result.all()
]
targets = result.all()
# Resolve chat names for telegram targets
chat_names: dict[str, str] = {}
for tgt in targets:
if tgt.type == "telegram" and tgt.config.get("chat_id"):
bot_id = tgt.config.get("bot_id")
chat_id = str(tgt.config["chat_id"])
if bot_id:
chat_result = await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id == chat_id,
)
)
chat = chat_result.first()
if chat:
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
return [_target_response(t, chat_names) for t in targets]
@router.post("", status_code=status.HTTP_201_CREATED)
@@ -69,7 +75,6 @@ async def create_target(
name=body.name,
icon=body.icon,
config=body.config,
template_config_id=body.template_config_id,
)
session.add(target)
await session.commit()
@@ -85,14 +90,7 @@ async def get_target(
):
"""Get a specific notification target."""
target = await _get_user_target(session, target_id, user.id)
return {
"id": target.id,
"type": target.type,
"name": target.name,
"icon": target.icon,
"config": _safe_config(target),
"template_config_id": target.template_config_id,
}
return _target_response(target)
@router.put("/{target_id}")
@@ -104,14 +102,8 @@ async def update_target(
):
"""Update a notification target."""
target = await _get_user_target(session, target_id, user.id)
if body.name is not None:
target.name = body.name
if body.icon is not None:
target.icon = body.icon
if body.config is not None:
target.config = body.config
if body.template_config_id is not None:
target.template_config_id = body.template_config_id
for field, value in body.model_dump(exclude_unset=True).items():
setattr(target, field, value)
session.add(target)
await session.commit()
await session.refresh(target)
@@ -124,8 +116,14 @@ async def delete_target(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a notification target."""
"""Delete a notification target and its tracker links."""
target = await _get_user_target(session, target_id, user.id)
# Delete associated tracker-target links
result = await session.exec(
select(TrackerTarget).where(TrackerTarget.target_id == target_id)
)
for tt in result.all():
await session.delete(tt)
await session.delete(target)
await session.commit()
@@ -133,16 +131,36 @@ async def delete_target(
@router.post("/{target_id}/test")
async def test_target(
target_id: int,
locale: str = Query("en"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test notification to a target."""
target = await _get_user_target(session, target_id, user.id)
from ..services.notifier import send_test_notification
result = await send_test_notification(target)
result = await send_test_notification(target, locale=locale)
return result
def _target_response(target: NotificationTarget, chat_names: dict[str, str] | None = None) -> dict:
resp = {
"id": target.id,
"type": target.type,
"name": target.name,
"icon": target.icon,
"config": _safe_config(target),
"created_at": target.created_at.isoformat(),
}
# Attach resolved chat name for telegram targets
if target.type == "telegram" and chat_names:
bot_id = target.config.get("bot_id")
chat_id = str(target.config.get("chat_id", ""))
key = f"{bot_id}_{chat_id}"
if key in chat_names:
resp["chat_name"] = chat_names[key]
return resp
def _safe_config(target: NotificationTarget) -> dict:
"""Return config with sensitive fields masked."""
config = dict(target.config)
@@ -1,6 +1,6 @@
"""Telegram bot management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -23,7 +23,7 @@ class BotCreate(BaseModel):
class BotUpdate(BaseModel):
name: str | None = None
commands_config: dict | None = None
icon: str | None = None
@router.get("")
@@ -69,12 +69,12 @@ async def update_bot(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a bot's display name and/or commands config."""
"""Update a bot's display name and icon."""
bot = await _get_user_bot(session, bot_id, user.id)
if body.name is not None:
bot.name = body.name
if body.commands_config is not None:
bot.commands_config = body.commands_config
if body.icon is not None:
bot.icon = body.icon
session.add(bot)
await session.commit()
await session.refresh(bot)
@@ -173,6 +173,37 @@ async def discover_chats(
return [_chat_response(c) for c in result.all()]
@router.post("/{bot_id}/chats/{chat_id}/test")
async def test_chat(
bot_id: int,
chat_id: str,
locale: str = Query("en"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test message to a chat via the bot."""
from ..services.notifier import _get_test_message
bot = await _get_user_bot(session, bot_id, user.id)
message = _get_test_message(locale, "telegram")
try:
async with aiohttp.ClientSession() as http:
async with http.post(
f"{TELEGRAM_API_BASE_URL}{bot.token}/sendMessage",
json={
"chat_id": chat_id,
"text": message,
"parse_mode": "HTML",
},
) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as e:
return {"success": False, "error": str(e)}
@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_chat(
bot_id: int,
@@ -247,10 +278,10 @@ def _bot_response(b: TelegramBot) -> dict:
return {
"id": b.id,
"name": b.name,
"icon": b.icon,
"bot_username": b.bot_username,
"bot_id": b.bot_id,
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
"commands_config": b.commands_config,
"created_at": b.created_at.isoformat(),
}
@@ -32,6 +32,7 @@ _SAMPLE_ASSET = {
"state": "Ile-de-France",
"country": "France",
"url": "https://immich.example.com/photos/abc123",
"public_url": "https://immich.example.com/share/abc123/photos/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"download_url": "https://immich.example.com/api/assets/abc123/original",
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
}
@@ -44,12 +45,14 @@ _SAMPLE_VIDEO_ASSET = {
"is_favorite": False,
"rating": None,
"photo_url": None,
"public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef",
"playback_url": "https://immich.example.com/api/assets/def456/video",
}
_SAMPLE_COLLECTION = {
"name": "Family Photos",
"url": "https://immich.example.com/share/abc123",
"public_url": "https://immich.example.com/share/abc123",
"asset_count": 42,
"shared": True,
}
@@ -85,10 +88,24 @@ _SAMPLE_CONTEXT = {
"new_name": "New Album",
"old_shared": False,
"new_shared": True,
# Public share URLs (may be empty if no shared link exists)
"public_url": "https://immich.example.com/share/abc123",
"protected_url": "",
"album_url": "https://immich.example.com/albums/b2eeeaa4",
# Common date/location (set when all assets share the same value)
"common_date": "19.03.2026",
"common_location": "Paris, France",
# Date format strings (from template config)
"date_format": "%d.%m.%Y, %H:%M UTC",
"date_only_format": "%d.%m.%Y",
# Scheduled/periodic variables (for those templates)
"collections": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "filename": "IMG_002.jpg", "city": "London", "country": "UK"}],
"albums": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "id": "x1y2z3", "filename": "IMG_002.jpg", "city": "London", "country": "UK", "public_url": "https://immich.example.com/share/abc123/photos/x1y2z3"}],
"date": "2026-03-19",
"photo_count": 30,
"video_count": 5,
"owner": "Alice",
}
@@ -106,6 +123,7 @@ class TemplateConfigCreate(BaseModel):
scheduled_assets_message: str | None = None
memory_mode_message: str | None = None
date_format: str | None = None
date_only_format: str | None = None
TemplateConfigUpdate = TemplateConfigCreate # Same shape, all optional
@@ -142,10 +160,15 @@ async def get_template_variables():
"collection_id": "Collection ID (UUID)",
"collection_name": "Collection name",
"collection_url": "Public share URL (empty if not shared)",
"public_url": "Public share link URL (empty if no link exists)",
"protected_url": "Password-protected share link URL (empty if none)",
"added_count": "Number of assets added",
"removed_count": "Number of assets removed",
"people": "Detected people names (list, use {{ people | join(', ') }})",
"shared": "Whether collection is shared (boolean)",
"photo_count": "Total photo count in album",
"video_count": "Total video count in album",
"owner": "Album owner name",
"target_type": "Target type: 'telegram' or 'webhook'",
"has_videos": "Whether added assets contain videos (boolean)",
"has_photos": "Whether added assets contain photos (boolean)",
@@ -177,6 +200,7 @@ async def get_template_variables():
"city": "City name",
"state": "State/region name",
"country": "Country name",
"public_url": "Per-asset public share URL (empty if no album link)",
"url": "Public viewer URL (if shared)",
"download_url": "Direct download URL (if shared)",
"photo_url": "Preview image URL (images only, if shared)",
@@ -185,6 +209,7 @@ async def get_template_variables():
album_fields = {
"name": "Collection/album name",
"url": "Share URL",
"public_url": "Public share link URL",
"asset_count": "Total assets in collection",
"shared": "Whether collection is shared",
}
@@ -196,7 +221,12 @@ async def get_template_variables():
return {
"message_assets_added": {
"description": "Notification when new assets are added to a collection",
"variables": {**event_vars, "added_assets": "List of asset dicts (use {% for asset in added_assets %})"},
"variables": {
**event_vars,
"added_assets": "List of asset dicts (use {% for asset in added_assets %})",
"common_date": "Shared date if all assets have the same date (formatted via date_only_format, empty otherwise)",
"common_location": "Shared location if all assets are from the same place (e.g. 'Paris, France', empty otherwise)",
},
"asset_fields": asset_fields,
},
"message_assets_removed": {
@@ -308,6 +338,8 @@ async def preview_config(
class PreviewRequest(BaseModel):
template: str
target_type: str = "telegram" # "telegram" or "webhook"
date_format: str = "%d.%m.%Y, %H:%M UTC"
date_only_format: str = "%d.%m.%Y"
@router.post("/preview-raw")
@@ -334,7 +366,14 @@ async def preview_raw(
# Pass 2: render with strict undefined to catch unknown variables
try:
ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type}
from datetime import datetime
ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type,
"date_format": body.date_format, "date_only_format": body.date_only_format}
# Format common_date using the provided date_only_format
try:
ctx["common_date"] = datetime(2026, 3, 19).strftime(body.date_only_format)
except (ValueError, TypeError):
ctx["common_date"] = "19.03.2026"
strict_env = SandboxedEnvironment(autoescape=False, undefined=StrictUndefined)
tmpl = strict_env.from_string(body.template)
rendered = tmpl.render(**ctx)
@@ -2,7 +2,7 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -152,6 +152,7 @@ async def delete_tracker_target(
async def test_tracker_target(
tracker_id: int,
tracker_target_id: int,
locale: str = Query("en"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
@@ -166,7 +167,7 @@ async def test_tracker_target(
raise HTTPException(status_code=404, detail="Target not found")
from ..services.notifier import send_test_notification
r = await send_test_notification(target)
r = await send_test_notification(target, locale=locale)
return {"target": target.name, **r}
@@ -227,7 +227,7 @@ async def tracker_history(
"collection_id": e.collection_id,
"collection_name": e.collection_name,
"details": e.details,
"created_at": e.created_at.isoformat(),
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
}
for e in result.all()
]
@@ -0,0 +1,252 @@
"""Data migrations for schema changes.
Handles converting legacy JSON-array relationships to proper junction tables.
"""
import logging
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine
logger = logging.getLogger(__name__)
async def migrate_schema(engine: AsyncEngine) -> None:
"""Add missing columns to existing tables (SQLite ALTER TABLE ADD COLUMN)."""
async with engine.begin() as conn:
# Helper to check if column exists
async def _has_column(table: str, column: str) -> bool:
cols = await conn.run_sync(
lambda sync_conn: [
row[1]
for row in sync_conn.execute(
text(f"PRAGMA table_info('{table}')")
).fetchall()
]
)
return column in cols
# Add batch_duration to tracker if missing
if not await _has_column("tracker", "batch_duration"):
await conn.execute(
text("ALTER TABLE tracker ADD COLUMN batch_duration INTEGER DEFAULT 0")
)
logger.info("Added batch_duration column to tracker table")
# Add enriched fields to event_log if missing
for col, sql in [
("tracker_name", "ALTER TABLE event_log ADD COLUMN tracker_name TEXT DEFAULT ''"),
("provider_id", "ALTER TABLE event_log ADD COLUMN provider_id INTEGER"),
("provider_name", "ALTER TABLE event_log ADD COLUMN provider_name TEXT DEFAULT ''"),
("assets_count", "ALTER TABLE event_log ADD COLUMN assets_count INTEGER DEFAULT 0"),
]:
if not await _has_column("event_log", col):
await conn.execute(text(sql))
logger.info("Added %s column to event_log table", col)
# Add date_only_format to template_config if missing
if not await _has_column("template_config", "date_only_format"):
await conn.execute(
text("ALTER TABLE template_config ADD COLUMN date_only_format TEXT DEFAULT '%d.%m.%Y'")
)
logger.info("Added date_only_format column to template_config table")
# Add collection_name and shared to tracker_state if missing
if not await _has_column("tracker_state", "collection_name"):
await conn.execute(
text("ALTER TABLE tracker_state ADD COLUMN collection_name TEXT DEFAULT ''")
)
logger.info("Added collection_name column to tracker_state table")
if not await _has_column("tracker_state", "shared"):
await conn.execute(
text("ALTER TABLE tracker_state ADD COLUMN shared INTEGER DEFAULT 0")
)
logger.info("Added shared column to tracker_state table")
async def migrate_tracker_targets(engine: AsyncEngine) -> None:
"""Migrate legacy Tracker.target_ids JSON arrays to TrackerTarget rows.
Also migrates:
- Tracker.tracking_config_id TrackerTarget.tracking_config_id
- Tracker.quiet_hours_* TrackerTarget.quiet_hours_*
- NotificationTarget.template_config_id TrackerTarget.template_config_id
- TelegramBot.commands_config TrackerTarget.commands_config (for telegram targets)
Idempotent: skips if legacy columns don't exist or data already migrated.
"""
async with engine.begin() as conn:
# Check if legacy target_ids column exists on tracker table
columns = await conn.run_sync(
lambda sync_conn: [
row[1]
for row in sync_conn.execute(
text("PRAGMA table_info('tracker')")
).fetchall()
]
)
if "target_ids" not in columns:
logger.debug("No legacy target_ids column found — skipping migration")
return
# Check if tracker_target table already has data (previous migration ran)
tt_count = (
await conn.execute(text("SELECT COUNT(*) FROM tracker_target"))
).scalar()
if tt_count and tt_count > 0:
logger.debug(
"tracker_target table already has %d rows — skipping migration",
tt_count,
)
return
# Load legacy data
trackers = (
await conn.execute(
text(
"SELECT id, target_ids, tracking_config_id, "
"quiet_hours_start, quiet_hours_end FROM tracker"
)
)
).fetchall()
if not trackers:
logger.debug("No trackers to migrate")
return
# Load template_config_id from targets (legacy field)
target_template_map: dict[int, int | None] = {}
target_cols = await conn.run_sync(
lambda sync_conn: [
row[1]
for row in sync_conn.execute(
text("PRAGMA table_info('notification_target')")
).fetchall()
]
)
if "template_config_id" in target_cols:
targets = (
await conn.execute(
text(
"SELECT id, template_config_id FROM notification_target"
)
)
).fetchall()
for t in targets:
target_template_map[t[0]] = t[1]
# Load commands_config from telegram_bots (legacy field)
bot_commands_map: dict[int, str | None] = {}
bot_cols = await conn.run_sync(
lambda sync_conn: [
row[1]
for row in sync_conn.execute(
text("PRAGMA table_info('telegram_bot')")
).fetchall()
]
)
if "commands_config" in bot_cols:
bots = (
await conn.execute(
text("SELECT id, commands_config FROM telegram_bot")
)
).fetchall()
for b in bots:
bot_commands_map[b[0]] = b[1]
# Build target → bot mapping for commands_config migration
target_bot_map: dict[int, int] = {}
if bot_commands_map:
import json
tgt_rows = (
await conn.execute(
text("SELECT id, config FROM notification_target WHERE type='telegram'")
)
).fetchall()
for tgt in tgt_rows:
try:
cfg = json.loads(tgt[1]) if isinstance(tgt[1], str) else tgt[1]
if cfg and "bot_token" in cfg:
for bot_id, _ in bot_commands_map.items():
bot_row = (
await conn.execute(
text("SELECT id FROM telegram_bot WHERE id=:bid"),
{"bid": bot_id},
)
).fetchone()
if bot_row:
# Match by checking if this target uses this bot's token
bot_token_row = (
await conn.execute(
text(
"SELECT token FROM telegram_bot WHERE id=:bid"
),
{"bid": bot_id},
)
).fetchone()
if bot_token_row and bot_token_row[0] == cfg.get(
"bot_token"
):
target_bot_map[tgt[0]] = bot_id
except Exception:
pass
# Create TrackerTarget rows
import json
migrated = 0
for tracker in trackers:
tracker_id = tracker[0]
raw_target_ids = tracker[1]
tracking_config_id = tracker[2]
quiet_hours_start = tracker[3]
quiet_hours_end = tracker[4]
# Parse target_ids JSON
if isinstance(raw_target_ids, str):
try:
target_ids = json.loads(raw_target_ids)
except (json.JSONDecodeError, TypeError):
target_ids = []
elif isinstance(raw_target_ids, list):
target_ids = raw_target_ids
else:
target_ids = []
for target_id in target_ids:
template_config_id = target_template_map.get(target_id)
# Get commands_config if this is a telegram target with a known bot
commands_config = None
if target_id in target_bot_map:
bot_id = target_bot_map[target_id]
raw_cmd = bot_commands_map.get(bot_id)
if raw_cmd:
commands_config = (
raw_cmd
if isinstance(raw_cmd, str)
else json.dumps(raw_cmd)
)
await conn.execute(
text(
"INSERT INTO tracker_target "
"(tracker_id, target_id, tracking_config_id, "
"template_config_id, enabled, quiet_hours_start, "
"quiet_hours_end, commands_config) "
"VALUES (:tid, :tgtid, :tcid, :tmplid, 1, :qhs, :qhe, :cmd)"
),
{
"tid": tracker_id,
"tgtid": target_id,
"tcid": tracking_config_id,
"tmplid": template_config_id,
"qhs": quiet_hours_start,
"qhe": quiet_hours_end,
"cmd": commands_config,
},
)
migrated += 1
logger.info("Migrated %d tracker-target links", migrated)
@@ -141,6 +141,7 @@ class TemplateConfig(SQLModel, table=True):
memory_mode_message: str = Field(default="")
date_format: str = Field(default="%d.%m.%Y, %H:%M UTC")
date_only_format: str = Field(default="%d.%m.%Y")
created_at: datetime = Field(default_factory=_utcnow)
@@ -221,8 +222,12 @@ class EventLog(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
tracker_id: int | None = Field(default=None, foreign_key="tracker.id")
tracker_name: str = Field(default="")
provider_id: int | None = Field(default=None)
provider_name: str = Field(default="")
event_type: str
collection_id: str
collection_name: str
assets_count: int = Field(default=0)
details: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
created_at: datetime = Field(default_factory=_utcnow)
@@ -1,15 +1,22 @@
"""Notify Bridge Server — FastAPI application entry point."""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
# Ensure app-level loggers are visible
logging.basicConfig(level=logging.INFO)
logging.getLogger("notify_bridge_server").setLevel(logging.DEBUG)
logging.getLogger("notify_bridge_core").setLevel(logging.DEBUG)
from .database.engine import init_db
from .database.models import * # noqa: F401,F403 — ensure all models registered
from .auth.routes import router as auth_router
from .api.providers import router as providers_router
from .api.trackers import router as trackers_router
from .api.tracker_targets import router as tracker_targets_router
from .api.tracking_configs import router as tracking_configs_router
from .api.template_configs import router as template_configs_router
from .api.targets import router as targets_router
@@ -22,6 +29,12 @@ from .api.template_vars import router as template_vars_router
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
# Run data migrations (idempotent)
from .database.engine import get_engine
from .database.migrations import migrate_schema, migrate_tracker_targets
engine = get_engine()
await migrate_schema(engine)
await migrate_tracker_targets(engine)
await _seed_default_templates()
from .services.scheduler import start_scheduler
await start_scheduler()
@@ -35,6 +48,7 @@ app.include_router(auth_router)
app.include_router(template_vars_router)
app.include_router(providers_router)
app.include_router(trackers_router)
app.include_router(tracker_targets_router)
app.include_router(tracking_configs_router)
app.include_router(template_configs_router)
app.include_router(targets_router)
@@ -49,7 +63,7 @@ async def health():
async def _seed_default_templates():
"""Seed default templates on first startup if no templates exist."""
"""Seed default templates on first startup if none exist."""
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
from .database.engine import get_engine
@@ -8,21 +8,37 @@ from ..database.models import NotificationTarget
_LOGGER = logging.getLogger(__name__)
_TEST_MESSAGES: dict[str, dict[str, str]] = {
"en": {
"telegram": "\u2705 Test message from <b>Notify Bridge</b>",
"webhook": "Test notification from Notify Bridge",
},
"ru": {
"telegram": "\u2705 Тестовое сообщение от <b>Notify Bridge</b>",
"webhook": "Тестовое уведомление от Notify Bridge",
},
}
async def send_test_notification(target: NotificationTarget) -> dict:
def _get_test_message(locale: str, target_type: str) -> str:
msgs = _TEST_MESSAGES.get(locale, _TEST_MESSAGES["en"])
return msgs.get(target_type, msgs.get("webhook", "Test"))
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
"""Send a simple test message to a notification target."""
try:
if target.type == "telegram":
return await _test_telegram(target)
return await _test_telegram(target, locale)
elif target.type == "webhook":
return await _test_webhook(target)
return await _test_webhook(target, locale)
return {"success": False, "error": f"Unknown target type: {target.type}"}
except Exception as e:
_LOGGER.error("Test notification failed: %s", e)
return {"success": False, "error": str(e)}
async def _test_telegram(target: NotificationTarget) -> dict:
async def _test_telegram(target: NotificationTarget, locale: str = "en") -> dict:
from notify_bridge_core.notifications.telegram.client import TelegramClient
bot_token = target.config.get("bot_token")
@@ -34,7 +50,7 @@ async def _test_telegram(target: NotificationTarget) -> dict:
client = TelegramClient(session, bot_token)
return await client.send_notification(
chat_id=str(chat_id),
caption="Test notification from Notify Bridge",
caption=_get_test_message(locale, "telegram"),
)
@@ -88,7 +104,7 @@ async def _test_webhook_with_message(target: NotificationTarget, message: str) -
return await client.send({"message": message, "event_type": "test_template"})
async def _test_webhook(target: NotificationTarget) -> dict:
async def _test_webhook(target: NotificationTarget, locale: str = "en") -> dict:
from notify_bridge_core.notifications.webhook.client import WebhookClient
url = target.config.get("url")
@@ -99,6 +115,6 @@ async def _test_webhook(target: NotificationTarget) -> dict:
async with aiohttp.ClientSession() as session:
client = WebhookClient(session, url, headers)
return await client.send({
"message": "Test notification from Notify Bridge",
"message": _get_test_message(locale, "webhook"),
"event_type": "test",
})
@@ -174,11 +174,16 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
session.add(new_ts)
for event in events:
assets_count = event.added_count or event.removed_count or 0
log = EventLog(
tracker_id=tracker_id,
tracker_name=tracker.name,
provider_id=provider.id,
provider_name=provider_name,
event_type=event.event_type.value,
collection_id=event.collection_id,
collection_name=event.collection_name,
assets_count=assets_count,
details={
"added_count": event.added_count,
"removed_count": event.removed_count,
@@ -233,8 +238,11 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
type=ld["target_type"],
config=ld["target_config"],
template_slots=slots,
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
date_only_format=tmpl.date_only_format if tmpl and hasattr(tmpl, "date_only_format") else "%d.%m.%Y",
provider_api_key=provider_config.get("api_key"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("external_domain", ""),
))
if target_configs:
@@ -0,0 +1,19 @@
# Feature Context: UX & Notification Improvements
## Current State
Starting implementation. All entity models already have `icon: str` fields. EventLog exists with basic fields. Immich client already has shared link CRUD methods. Frontend uses Svelte 5 + Tailwind v4 with inline form/card pattern on all CRUD pages.
## Temporary Workarounds
- None yet
## Cross-Phase Dependencies
- Phase 3 depends on Phase 2 (enriched event data from API)
- Phase 6 logically follows Phase 5 (link validation informs no-link handling)
- Phases 1, 4 are fully independent
## Implementation Notes
- All overlays MUST use `position: fixed` with inline styles and `z-index: 9999`
- SQLAlchemy async + aiohttp: eager load DB data before aiohttp context
- Jinja2 templates use SandboxedEnvironment
- Icons stored as MDI icon path names (e.g., `mdiCamera`) from `@mdi/js`
- Frontend uses MdiIcon component to render SVG icons
@@ -0,0 +1,44 @@
# Feature: UX & Notification Improvements
**Branch:** `feature/ux-notification-improvements`
**Base branch:** `feature/entity-relationship-refactor`
**Created:** 2026-03-20
**Status:** 🟡 In Progress
**Strategy:** Incremental
**Mode:** Automated
**Execution:** Orchestrator
## Summary
Seven UX and notification improvements: show entity icons, enrich event data, dashboard filtering/sorting, friendly Telegram chat names, bot test messages, album public link validation, and graceful degradation for albums without public links.
## Build & Test Commands
- **Build (backend):** `cd packages/server && pip install -e .`
- **Build (frontend):** `cd frontend && npx vite build`
- **Test (backend):** `cd packages/server && python -m pytest` (if tests exist)
- **Lint:** N/A
## Phases
- [ ] Phase 1: Show Entity Icons on Cards [domain: frontend] → [subplan](./phase-1-entity-icons.md)
- [ ] Phase 2: Enrich Event Data [domain: backend] → [subplan](./phase-2-enrich-events.md)
- [ ] Phase 3: Richer Events Display + Filtering & Sorting [domain: frontend] → [subplan](./phase-3-events-ui.md)
- [ ] Phase 4: Friendly Chat Names + Test Message for Bots [domain: fullstack] → [subplan](./phase-4-chat-names-test-msg.md)
- [ ] Phase 5: Album Public Link Validation [domain: fullstack] → [subplan](./phase-5-link-validation.md)
- [ ] Phase 6: Support Albums Without Public Links [domain: backend] → [subplan](./phase-6-no-link-support.md)
## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 1: Entity Icons | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 2: Enrich Events | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 3: Events UI | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 4: Chat Names + Test | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 5: Link Validation | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 6: No-Link Support | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
## Final Review
- [ ] Comprehensive code review
- [ ] Full build passes
- [ ] Full test suite passes
- [ ] Merged to `feature/entity-relationship-refactor`
@@ -0,0 +1,44 @@
# Phase 1: Show Entity Icons on Cards
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Display the user-selected icon on every entity card (Providers, Trackers, Targets, Telegram Bots). Currently the IconPicker saves the icon but cards use hardcoded default icons.
## Tasks
- [ ] Task 1: Update provider cards in `frontend/src/routes/providers/+page.svelte` to show `provider.icon` (fallback to default server/cloud icon)
- [ ] Task 2: Update tracker cards in `frontend/src/routes/trackers/+page.svelte` to show `tracker.icon` (fallback to default radar icon)
- [ ] Task 3: Update target cards in `frontend/src/routes/targets/+page.svelte` to show `target.icon` (fallback to type-based default)
- [ ] Task 4: Update bot cards in `frontend/src/routes/telegram-bots/+page.svelte` to show `bot.icon` (fallback to robot icon)
- [ ] Task 5: Ensure icon rendering uses MdiIcon component with proper sizing consistent with existing card headers
## Files to Modify/Create
- `frontend/src/routes/providers/+page.svelte` — card header icon
- `frontend/src/routes/trackers/+page.svelte` — card header icon
- `frontend/src/routes/targets/+page.svelte` — card header icon
- `frontend/src/routes/telegram-bots/+page.svelte` — card header icon
## Acceptance Criteria
- Each entity card displays the saved icon if set
- Falls back to a sensible default icon per entity type when no icon is saved
- Icon styling is consistent across all card types
- No regressions in existing card layout or functionality
## Notes
- Icons are stored as MDI path constant names (e.g., the string key from `@mdi/js`)
- The MdiIcon component already exists in `frontend/src/lib/components/MdiIcon.svelte`
- IconPicker component already handles icon selection and stores the value
- Need to check exactly how icon values are stored (full path data vs key name) to render correctly
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,45 @@
# Phase 2: Enrich Event Data
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Enrich EventLog with provider name, tracker name, and asset counts so the dashboard can display richer event details. Update the status API to return these fields.
## Tasks
- [ ] Task 1: Add `provider_name`, `tracker_name`, `provider_id` columns to EventLog model (or store in details JSON to avoid migration complexity)
- [ ] Task 2: Update watcher.py event logging to populate provider_name, tracker_name, and assets_count when creating EventLog entries
- [ ] Task 3: Update status.py GET /api/status endpoint to return enriched event fields (provider_name, tracker_name, event_type, collection_name, assets_count, details)
- [ ] Task 4: Add pagination/limit support to the events endpoint (query param `limit`, default 20)
- [ ] Task 5: Add optional filtering query params: `event_type`, `provider_id`, `search` (name match)
- [ ] Task 6: Handle migration for existing EventLog rows (backfill from tracker/provider if possible, or leave empty for old rows)
## Files to Modify/Create
- `packages/server/src/notify_bridge_server/database/models.py` — EventLog model changes
- `packages/server/src/notify_bridge_server/services/watcher.py` — populate new fields on event creation
- `packages/server/src/notify_bridge_server/api/status.py` — enrich response, add filtering/pagination
- `packages/server/src/notify_bridge_server/database/migrations.py` — add columns if using real columns
## Acceptance Criteria
- EventLog entries created after this phase include provider_name, tracker_name, assets_count
- GET /api/status returns enriched event data
- Filtering by event_type, provider_id, and text search works
- Old events without new fields still render (graceful degradation)
- No breaking changes to existing API consumers
## Notes
- Storing provider_name/tracker_name as denormalized strings is intentional — the event log should be a historical record even if the tracker/provider is later deleted
- The `details` JSON field already exists and could hold extra data, but explicit columns are better for filtering
- EventLog.tracker_id already exists as FK — can join for backfill but also store name directly
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,43 @@
# Phase 3: Richer Events Display + Filtering & Sorting
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Update the dashboard to display richer event details (provider name, tracker name, album name, event type, assets count) and add filtering/sorting controls.
## Tasks
- [ ] Task 1: Update event timeline items in `+page.svelte` to show: provider name, tracker name, album name, event type badge, and assets count
- [ ] Task 2: Add filter controls above the events list: text search input, event type dropdown (all/assets_added/assets_removed/collection_renamed/collection_deleted/sharing_changed), provider dropdown (populated from providers list)
- [ ] Task 3: Add sort control: newest first / oldest first toggle
- [ ] Task 4: Wire filters to API query params (event_type, provider_id, search) from Phase 2
- [ ] Task 5: Add "load more" button or increase default limit for events
- [ ] Task 6: Ensure graceful display when enriched fields are empty (old events before Phase 2)
## Files to Modify/Create
- `frontend/src/routes/+page.svelte` — event display, filter/sort controls, API calls
## Acceptance Criteria
- Each event shows: provider name, tracker name, album name, event type (badge), assets count
- Filtering by text search, event type, and provider works
- Sort by time (newest/oldest) works
- Old events without enriched data display gracefully (show what's available)
- Filter/sort state resets on page load (no persistence needed)
- UI is responsive and consistent with existing design
## Notes
- Depends on Phase 2's enriched API response
- Provider list for the dropdown can come from existing /api/providers endpoint
- Event type badges already have color mapping in the current dashboard code
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,50 @@
# Phase 4: Friendly Chat Names + Test Message for Bots
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Two Telegram UX improvements: (a) show friendly chat names instead of raw IDs on target cards, (b) add a "Send Test Message" button to each chat item on the bots page.
## Tasks
### 4a: Friendly Chat Names on Target Cards
- [ ] Task 1: Update GET /api/targets in targets.py to resolve chat_id → friendly name by looking up TelegramChat table (match on bot token's bot_id + chat_id)
- [ ] Task 2: Include `chat_name` field in target API response alongside chat_id
- [ ] Task 3: Update target cards in targets/+page.svelte to display "Chat Name (chat_id)" instead of raw chat_id
### 4b: Test Message Button for Bot Chats
- [ ] Task 4: Add POST /api/telegram-bots/{bot_id}/chats/{chat_id}/test endpoint in telegram_bots.py that sends a simple test message via the bot
- [ ] Task 5: Add "Send Test" button to each chat item in telegram-bots/+page.svelte with loading/success/error feedback
- [ ] Task 6: Handle edge cases (bot can't reach chat, chat deleted, etc.) with proper error messages
## Files to Modify/Create
- `packages/server/src/notify_bridge_server/api/targets.py` — resolve chat names
- `packages/server/src/notify_bridge_server/api/telegram_bots.py` — test message endpoint
- `frontend/src/routes/targets/+page.svelte` — display friendly names
- `frontend/src/routes/telegram-bots/+page.svelte` — test message button
## Acceptance Criteria
- Target cards show "Chat Title (chat_id)" for telegram targets where chat name is known
- Falls back to just chat_id when no matching TelegramChat record exists
- Test message button sends a simple "Test message from Notify Bridge" to the chat
- Button shows loading state, then success/error feedback
- Error messages are user-friendly (not raw API errors)
## Notes
- TelegramChat stores: chat_id (string), title, chat_type, username, bot_id (FK)
- NotificationTarget.config stores: bot_token, chat_id
- To resolve: need to find TelegramBot by token → get bot_id → lookup TelegramChat by (bot_id, chat_id)
- For test message: use TelegramClient directly with bot token from TelegramBot record
- The test endpoint on targets already exists (POST /api/targets/{id}/test) — this is a NEW endpoint specifically for bot chat items
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,48 @@
# Phase 5: Album Public Link Validation
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
When saving/updating a tracker with album selections, check if the selected albums have valid public shared links. Warn the user about missing links and offer to auto-create them.
## Tasks
- [ ] Task 1: Add GET /api/providers/{id}/albums/{album_id}/shared-links endpoint that wraps ImmichClient.get_shared_links()
- [ ] Task 2: Add POST /api/providers/{id}/albums/{album_id}/shared-links endpoint that wraps ImmichClient.create_shared_link()
- [ ] Task 3: In trackers/+page.svelte, after album selection changes (on save), call shared-links endpoint for each newly selected album
- [ ] Task 4: Show a warning dialog/section listing albums without valid public links (expired, password-protected, or missing)
- [ ] Task 5: Add "Auto-create public links" button in the warning dialog that calls the create endpoint for each missing album
- [ ] Task 6: Add hints explaining implications: "Public links allow anyone with the URL to view album contents" and "Albums without public links will have limited notification features (no clickable links in messages)"
- [ ] Task 7: Allow user to proceed without creating links (dismiss warning and save anyway)
## Files to Modify/Create
- `packages/server/src/notify_bridge_server/api/trackers.py` or new `providers.py` routes — shared link endpoints
- `frontend/src/routes/trackers/+page.svelte` — validation UI, warning dialog, auto-create flow
## Acceptance Criteria
- On tracker save with new albums, shared links are checked
- Albums without valid links are highlighted with a warning
- User can auto-create links with one click
- User can dismiss and proceed without links
- Hints explain security/privacy implications
- Already-valid links are not re-created
- Expired or password-protected links are flagged as problematic
## Notes
- ImmichClient already has: get_shared_links(album_id), create_shared_link(album_id, ...), delete_shared_link(), set_shared_link_password()
- SharedLinkInfo model has: id, key, has_password, is_expired, is_accessible
- A "valid" link = exists AND not expired AND is_accessible (has_password is a warning, not a blocker)
- The check should only run for NEWLY selected albums (not all albums on every save)
- Use a modal/dialog for the warning — follows project convention of fixed-position overlays
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,47 @@
# Phase 6: Support Albums Without Public Links
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Allow tracking albums even without public links. Templates should conditionally wrap items in `<a href>` when public URLs exist, otherwise show plain text names. Telegram should still send assets to chats regardless of public link status.
## Tasks
- [ ] Task 1: In template context building (context.py), include `has_public_url` / `album_url` / per-asset `public_url` flags so templates can conditionally render links
- [ ] Task 2: Update default system templates (EN/RU seeds) to use `{% if album_url %}` / `{% if asset.public_url %}` conditionals — wrap in `<a href>` when URL exists, plain text otherwise
- [ ] Task 3: Verify that Telegram notification sending (notifier.py + telegram client) sends assets via direct API download regardless of public link status (it should already work since it uses internal API URLs with api_key headers, not public links)
- [ ] Task 4: Ensure the template context correctly distinguishes between internal API URLs (for media download) and public URLs (for user-facing links in messages)
- [ ] Task 5: Test that events from albums without public links still generate notifications with asset media but no clickable links in the message text
## Files to Modify/Create
- `packages/core/src/notify_bridge_core/templates/context.py` — add public_url flags to context
- `packages/core/src/notify_bridge_core/providers/immich/provider.py` — ensure shared link info flows through events
- `packages/server/src/notify_bridge_server/services/notifier.py` — verify asset sending works without public links
- Default template seeds (wherever EN/RU templates are defined) — conditional link rendering
## Acceptance Criteria
- Albums without public links can be tracked without errors
- Notifications are sent with media assets regardless of public link status
- Message text includes clickable links only when public URLs exist
- Message text shows plain album/asset names when no public URL
- Default EN/RU templates handle both cases
- No regressions for albums that DO have public links
## Notes
- Internal asset URLs use format: `{provider_url}/api/assets/{id}/original` with x-api-key header
- Public URLs use format: `{external_domain}/share/{key}` (no auth needed)
- Telegram client downloads via internal URLs (with headers) — this is independent of public links
- The public URL is only relevant for the message text (human-readable links)
- Template context already has `album_url` from event.extra — need to make it None/empty when no shared link
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->