Phase 8: Server health, album filter, Jinja2 engine, password change
Some checks failed
Validate / Hassfest (push) Has been cancelled

5 features implemented:

1. Server health indicator: green/red/yellow dot on each server card.
   Pings Immich in background on page load. New GET /api/servers/{id}/ping.

2. Album selector filter: search input above album list in tracker form.
   Filters by name as you type (case-insensitive). Shows total count.

3. Album last update time: each album in the selector shows its
   updatedAt date. Backend now returns updatedAt from Immich API.

4. Full Jinja2 template engine in notifier:
   - build_full_context() assembles all ~40 variables from blueprint
   - Common date/location detection across assets
   - Per-asset date/location when they differ
   - Favorite indicator, people formatting, asset list with truncation
   - Video warning for Telegram
   - All template slots from TemplateConfig used contextually

5. Password change: PUT /api/auth/password endpoint (validates current
   password, min 6 chars). UI in sidebar footer with inline form.

Also: Phase 9 plan (Telegram bot management) added.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 17:27:00 +03:00
parent 431069fbdb
commit 0200b9929f
10 changed files with 269 additions and 52 deletions

2
.gitignore vendored
View File

@@ -44,3 +44,5 @@ htmlcov/
# Claude Code
.claude/
__pycache__/
test-data/

View File

@@ -244,6 +244,10 @@
"dark": "Dark",
"system": "System",
"test": "Test",
"create": "Create"
"create": "Create",
"changePassword": "Change Password",
"currentPassword": "Current password",
"newPassword": "New password",
"passwordChanged": "Password changed successfully"
}
}

View File

@@ -244,6 +244,10 @@
"dark": "Тёмная",
"system": "Системная",
"test": "Тест",
"create": "Создать"
"create": "Создать",
"changePassword": "Сменить пароль",
"currentPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"passwordChanged": "Пароль успешно изменён"
}
}

View File

@@ -3,6 +3,7 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
@@ -11,6 +12,21 @@
const auth = getAuth();
const theme = getTheme();
let showPasswordForm = $state(false);
let pwdCurrent = $state('');
let pwdNew = $state('');
let pwdMsg = $state('');
async function changePassword(e: SubmitEvent) {
e.preventDefault(); pwdMsg = '';
try {
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
pwdMsg = t('common.passwordChanged');
pwdCurrent = ''; pwdNew = '';
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; }, 2000);
} catch (err: any) { pwdMsg = err.message; }
}
// Reactive counter to force re-render on locale change
let localeVersion = $state(0);
let collapsed = $state(false);
@@ -146,17 +162,33 @@
</button>
{:else}
<div class="flex items-center justify-between px-1">
<div>
<p class="text-sm font-medium">{auth.user.username}</p>
<p class="text-xs text-[var(--color-muted-foreground)]">{auth.user.role}</p>
<div class="px-1">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">{auth.user.username}</p>
<p class="text-xs text-[var(--color-muted-foreground)]">{auth.user.role}</p>
</div>
<button onclick={logout}
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors">
{tt('nav.logout')}
</button>
</div>
<button
onclick={logout}
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
>
{tt('nav.logout')}
<button onclick={() => showPasswordForm = !showPasswordForm}
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] mt-1">
🔑 {tt('common.changePassword')}
</button>
{#if showPasswordForm}
<form onsubmit={changePassword} class="mt-2 space-y-2">
<input type="password" bind:value={pwdCurrent} required placeholder={tt('common.currentPassword')}
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded bg-[var(--color-background)]" />
<input type="password" bind:value={pwdNew} required placeholder={tt('common.newPassword')}
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded bg-[var(--color-background)]" />
{#if pwdMsg}<p class="text-xs text-[var(--color-muted-foreground)]">{pwdMsg}</p>{/if}
<button type="submit" class="w-full px-2 py-1 text-xs bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded hover:opacity-90">
{tt('common.save')}
</button>
</form>
{/if}
</div>
{/if}
</div>

View File

@@ -14,8 +14,17 @@
let submitting = $state(false);
let loaded = $state(false);
let health = $state<Record<number, boolean | null>>({});
onMount(load);
async function load() { try { servers = await api('/servers'); } catch {} finally { loaded = true; } }
async function load() {
try { servers = await api('/servers'); } catch {} finally { loaded = true; }
// Ping all servers in background
for (const s of servers) {
health[s.id] = null; // loading
api(`/servers/${s.id}/ping`).then(r => health[s.id] = r.online).catch(() => health[s.id] = false);
}
}
function openNew() {
form = { name: 'Immich', url: '', api_key: '' };
@@ -88,9 +97,13 @@
{#each servers as server}
<Card>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{server.name}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{server.url}</p>
<div class="flex items-center gap-2">
<span class="inline-block w-2.5 h-2.5 rounded-full {health[server.id] === true ? 'bg-green-500' : health[server.id] === false ? 'bg-red-500' : 'bg-yellow-400 animate-pulse'}"
title={health[server.id] === true ? 'Online' : health[server.id] === false ? 'Offline' : 'Checking...'}></span>
<div>
<p class="font-medium">{server.name}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{server.url}</p>
</div>
</div>
<div class="flex items-center gap-3">
<button onclick={() => edit(server)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('common.edit')}</button>

View File

@@ -13,6 +13,7 @@
let albums = $state<any[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
let albumFilter = $state('');
const defaultForm = () => ({
name: '', server_id: 0, album_ids: [] as string[],
target_ids: [] as number[], scan_interval: 60,
@@ -84,12 +85,19 @@
</div>
{#if albums.length > 0}
<div>
<label class="block text-sm font-medium mb-1">{t('trackers.albums')}</label>
<div class="max-h-48 overflow-y-auto border border-[var(--color-border)] rounded-md p-2 space-y-1">
{#each albums as album}
<label class="flex items-center gap-2 text-sm cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
<input type="checkbox" checked={form.album_ids.includes(album.id)} onchange={() => toggleAlbum(album.id)} />
{album.albumName} <span class="text-[var(--color-muted-foreground)]">({album.assetCount})</span>
<label class="block text-sm font-medium mb-1">{t('trackers.albums')} ({albums.length})</label>
<input type="text" bind:value={albumFilter} placeholder="Filter albums..."
class="w-full px-3 py-1.5 mb-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="max-h-56 overflow-y-auto border border-[var(--color-border)] rounded-md p-2 space-y-1">
{#each albums.filter(a => !albumFilter || a.albumName.toLowerCase().includes(albumFilter.toLowerCase())) as album}
<label class="flex items-center justify-between text-sm cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
<span class="flex items-center gap-2">
<input type="checkbox" checked={form.album_ids.includes(album.id)} onchange={() => toggleAlbum(album.id)} />
{album.albumName} <span class="text-[var(--color-muted-foreground)]">({album.assetCount})</span>
</span>
{#if album.updatedAt}
<span class="text-xs text-[var(--color-muted-foreground)]">{new Date(album.updatedAt).toLocaleDateString()}</span>
{/if}
</label>
{/each}
</div>

View File

@@ -126,6 +126,20 @@ async def delete_server(
await session.commit()
@router.get("/{server_id}/ping")
async def ping_server(
server_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Check if an Immich server is reachable."""
server = await _get_user_server(session, server_id, user.id)
async with aiohttp.ClientSession() as http_session:
client = ImmichClient(http_session, server.url, server.api_key)
ok = await client.ping()
return {"online": ok}
@router.get("/{server_id}/albums")
async def list_albums(
server_id: int,
@@ -143,6 +157,7 @@ async def list_albums(
"albumName": a.get("albumName"),
"assetCount": a.get("assetCount", 0),
"shared": a.get("shared", False),
"updatedAt": a.get("updatedAt", ""),
}
for a in albums
]

View File

@@ -130,6 +130,28 @@ async def me(user: User = Depends(get_current_user)):
return UserResponse(id=user.id, username=user.username, role=user.role)
class PasswordChangeRequest(BaseModel):
current_password: str
new_password: str
@router.put("/password")
async def change_password(
body: PasswordChangeRequest,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Change current user's password."""
if not _verify_password(body.current_password, user.hashed_password):
raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(body.new_password) < 6:
raise HTTPException(status_code=400, detail="New password must be at least 6 characters")
user.hashed_password = _hash_password(body.new_password)
session.add(user)
await session.commit()
return {"success": True}
@router.get("/needs-setup")
async def needs_setup(session: AsyncSession = Depends(get_session)):
"""Check if initial setup is needed (no users exist)."""

View File

@@ -1,8 +1,9 @@
"""Notification dispatch service."""
"""Notification dispatch service with full Jinja2 template rendering."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
import aiohttp
@@ -16,18 +17,125 @@ from ..webhook.client import WebhookClient
_LOGGER = logging.getLogger(__name__)
# Default template used when no custom template is configured
_env = SandboxedEnvironment(autoescape=False)
# Default template (Jinja2 syntax) when no config is assigned
DEFAULT_TEMPLATE = (
"{{ added_count }} new item(s) added to album \"{{ album_name }}\"."
"{% if people %}\nPeople: {{ people | join(', ') }}{% endif %}"
'{{ added_count }} new item(s) added to album "{{ album_name }}".'
'{% if people %}\nPeople: {{ people | join(", ") }}{% endif %}'
)
def render_template(template_body: str, context: dict[str, Any]) -> str:
"""Render a Jinja2 template with the given context."""
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template_body)
return tmpl.render(**context)
def _render(template_str: str, ctx: dict[str, Any]) -> str:
"""Render a Jinja2 template string with context. Falls back on error."""
try:
return _env.from_string(template_str).render(**ctx)
except jinja2.TemplateError as e:
_LOGGER.error("Template render error: %s", e)
return template_str
def build_full_context(
event_data: dict[str, Any],
template_config: TemplateConfig | None = None,
) -> dict[str, Any]:
"""Build the full template context with all variables from the blueprint.
This assembles the ~40 variables the blueprint supports:
- Direct event fields (album_name, added_count, etc.)
- Computed fields (common_date, common_location, people, assets, video_warning)
- Formatted sub-templates (asset items, people format, etc.)
"""
tc = template_config
ctx = dict(event_data)
# People formatting
people_list = ctx.get("people", [])
if isinstance(people_list, list) and people_list and tc:
people_str = ", ".join(str(p) for p in people_list)
ctx["people"] = _render(tc.message_people_format, {"people": people_str})
elif isinstance(people_list, list):
ctx["people"] = ", ".join(str(p) for p in people_list) if people_list else ""
else:
ctx["people"] = str(people_list) if people_list else ""
# Asset list formatting
added_assets = ctx.get("added_assets", [])
if added_assets and tc:
date_fmt = tc.date_format or "%d.%m.%Y, %H:%M UTC"
# Detect common date/location
dates = set()
locations = set()
for a in added_assets:
if a.get("created_at"):
try:
dt = datetime.fromisoformat(str(a["created_at"]).replace("Z", "+00:00"))
dates.add(dt.strftime(date_fmt))
except (ValueError, TypeError):
pass
loc_parts = [a.get("city"), a.get("country")]
loc = ", ".join(p for p in loc_parts if p)
if loc:
locations.add(loc)
common_date = ""
if len(dates) == 1:
common_date = _render(tc.common_date_template, {"date": next(iter(dates))})
ctx["common_date"] = common_date
common_location = ""
if len(locations) == 1:
common_location = _render(tc.common_location_template, {"location": next(iter(locations))})
ctx["common_location"] = common_location
# Format individual assets
asset_lines = []
for a in added_assets:
asset_type = a.get("type", "IMAGE")
tmpl = tc.message_asset_image if asset_type == "IMAGE" else tc.message_asset_video
# Per-asset date (only if dates differ)
created_if_unique = ""
if len(dates) > 1 and a.get("created_at"):
try:
dt = datetime.fromisoformat(str(a["created_at"]).replace("Z", "+00:00"))
created_if_unique = _render(tc.date_if_unique_template, {"date": dt.strftime(date_fmt)})
except (ValueError, TypeError):
pass
# Per-asset location
location_if_unique = ""
loc_parts = [a.get("city"), a.get("country")]
loc = ", ".join(p for p in loc_parts if p)
if loc and len(locations) > 1:
location_if_unique = _render(tc.location_if_unique_template, {"location": loc})
# Favorite indicator
fav = tc.favorite_indicator if a.get("is_favorite") else ""
asset_ctx = {
**a,
"created_if_unique": created_if_unique,
"location_if_unique": location_if_unique,
"location": loc,
"is_favorite": fav,
}
asset_lines.append(_render(tmpl, asset_ctx))
# Assemble assets list
assets_str = "".join(asset_lines)
ctx["assets"] = _render(tc.message_assets_format, {"assets": assets_str})
else:
ctx.setdefault("assets", "")
ctx.setdefault("common_date", "")
ctx.setdefault("common_location", "")
# Video warning
has_videos = any(a.get("type") == "VIDEO" for a in added_assets) if added_assets else False
ctx["video_warning"] = (tc.video_warning if tc and has_videos else "")
return ctx
async def send_notification(
@@ -36,14 +144,7 @@ async def send_notification(
template_config: TemplateConfig | None = None,
use_ai_caption: bool = False,
) -> dict[str, Any]:
"""Send a notification to a target using event data.
Args:
target: Notification destination (telegram or webhook)
event_data: Album change event data (album_name, added_count, etc.)
template_config: Optional template config with per-event templates
use_ai_caption: If True, generate caption with Claude AI instead of template
"""
"""Send a notification to a target using event data."""
message = None
# Try AI caption first if enabled
@@ -52,11 +153,12 @@ async def send_notification(
if is_ai_enabled():
message = await generate_caption(event_data)
# Fall back to template rendering
# Render with template engine
if message is None:
ctx = build_full_context(event_data, template_config)
template_body = DEFAULT_TEMPLATE
if template_config:
# Select the right template slot based on event type
change_type = event_data.get("change_type", "")
slot_map = {
"assets_added": "message_assets_added",
@@ -66,18 +168,14 @@ async def send_notification(
}
slot = slot_map.get(change_type, "message_assets_added")
template_body = getattr(template_config, slot, DEFAULT_TEMPLATE) or DEFAULT_TEMPLATE
try:
message = render_template(template_body, event_data)
except jinja2.TemplateError as e:
_LOGGER.error("Template rendering failed: %s", e)
message = f"Album changed: {event_data.get('album_name', 'unknown')}"
message = _render(template_body, ctx)
if target.type == "telegram":
return await _send_telegram(target, message, event_data)
elif target.type == "webhook":
return await _send_webhook(target, message, event_data)
else:
return {"success": False, "error": f"Unknown target type: {target.type}"}
return {"success": False, "error": f"Unknown target type: {target.type}"}
async def send_test_notification(target: NotificationTarget) -> dict[str, Any]:
@@ -116,7 +214,6 @@ async def _send_telegram(
async with aiohttp.ClientSession() as session:
client = TelegramClient(session, bot_token)
# Build assets list from event data for media sending
assets = []
for asset in event_data.get("added_assets", []):
url = asset.get("download_url") or asset.get("url")
@@ -142,10 +239,7 @@ async def _send_webhook(
if not url:
return {"success": False, "error": "Missing url in target config"}
payload = {
"message": message,
"event": event_data,
}
payload = {"message": message, "event": event_data}
async with aiohttp.ClientSession() as session:
client = WebhookClient(session, url, headers)

View File

@@ -0,0 +1,23 @@
# Phase 9: Telegram Bot Management
**Status**: Pending
**Parent**: [primary-plan.md](primary-plan.md)
---
## Tasks
### 1. Register Telegram bots `[ ]`
- New entity: TelegramBot (name, token, bot_username fetched via getMe)
- CRUD API: /api/telegram-bots
- Frontend page to manage bots
### 2. View active bot chats `[ ]`
- Call Telegram getUpdates API to discover chats the bot has interacted with
- Display chat list (id, title, type) per bot
- Refresh on demand
### 3. Target uses registered bot + chat selector `[ ]`
- Target form: select from registered bots (dropdown) instead of raw token
- Select from discovered chats instead of typing chat_id manually
- Keep backward-compatible raw input as fallback