Phase 8: Server health, album filter, Jinja2 engine, password change
Some checks failed
Validate / Hassfest (push) Has been cancelled
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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,3 +44,5 @@ htmlcov/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
__pycache__/
|
||||
test-data/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,10 @@
|
||||
"dark": "Тёмная",
|
||||
"system": "Системная",
|
||||
"test": "Тест",
|
||||
"create": "Создать"
|
||||
"create": "Создать",
|
||||
"changePassword": "Сменить пароль",
|
||||
"currentPassword": "Текущий пароль",
|
||||
"newPassword": "Новый пароль",
|
||||
"passwordChanged": "Пароль успешно изменён"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
23
plans/phase-9-telegram-bots.md
Normal file
23
plans/phase-9-telegram-bots.md
Normal 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
|
||||
Reference in New Issue
Block a user