Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5604c733d1 | |||
| 3b7808aa9c | |||
| 155d25edf9 | |||
| 69711bbc84 | |||
| fe38d20b96 | |||
| d02616069d | |||
| 7dae68fd93 | |||
| e6481605ca | |||
| 6de9a1289e | |||
| 325eabd751 | |||
| fab6169cf9 | |||
| 85311684d9 | |||
| d7daadadc2 | |||
| e04ad16ca6 | |||
| d7d0a5d921 | |||
| 93df538819 | |||
| 2be608ba95 |
+12
-21
@@ -1,32 +1,23 @@
|
||||
## v0.2.3 (2026-04-22)
|
||||
# v0.3.1 (2026-04-22)
|
||||
|
||||
Bot-command scope hardening: commands now see only what their chat is wired to
|
||||
receive notifications about, closing a leak where a bot serving multiple chats
|
||||
exposed the whole provider catalog to every chat. Plus a handful of Immich
|
||||
command fixes (missing `public_url` enrichment, silently-swallowed search errors,
|
||||
always-on link previews).
|
||||
Follow-up perf pass on top of v0.3.0's polling overhaul — extends the same
|
||||
caching discipline to the bot-command read paths so repeat `/random`,
|
||||
`/latest`, `/memory`, etc. against the same album don't each refetch a
|
||||
multi-megabyte album body or pay for a full server-wide `/api/shared-links`
|
||||
listing.
|
||||
|
||||
### Features
|
||||
## Performance
|
||||
|
||||
- **Per-chat album scope derived from notification routing** — for a `(provider, bot, chat_id)` triple, the allowed album set is now computed by walking `TargetReceiver → NotificationTarget → NotificationTrackerTarget → NotificationTracker` and unioning the collection IDs. `/albums`, `/random`, `/search`, `/find`, `/latest`, `/memory`, `/summary`, `/favorites`, `/place`, `/person`, `/status`, `/events` all intersect their results with the resolved scope. Chats with no notification routing for a tracker return nothing rather than leaking the provider's catalog. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
|
||||
- **Scope modal relabeled** — the per-listener `allowed_album_ids` UI is now explicitly an *override for this bot* (escape hatch when you want a divergent scope for a whole bot); the default is *derive from notification routing*, which matches what operators have already configured elsewhere. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
|
||||
- **Drop tracker counts from `/status`** — `trackers_active` / `trackers_total` were per-provider aggregates that would leak info about trackers a chat has no visibility into. Immich default `/status` templates (en, ru) now show only *Albums* + *Last event*; the template-editor variable catalog no longer suggests the removed vars for the Immich `/status` slot. **Note:** custom templates that reference `{{ trackers_active }}` / `{{ trackers_total }}` need to be updated. ([5a232f1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5a232f1))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **`/albums` honors per-chat scope** — previously ignored `CommandTrackerListener.allowed_album_ids` and listed every album tracked by the provider, so scoped chats saw neighbours' albums. Now applies the same intersect filter the `/_cmd_immich` media commands use. ([4ff3876](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4ff3876))
|
||||
- **Disable Telegram link previews on command text replies** — listings (`/albums`, `/events`, `/people`, …) embed multiple links and were rendering a preview for the first URL regardless of the operator's *Disable link previews* toggle. `send_reply` now always passes `disable_web_page_preview=True`. ([4ff3876](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4ff3876))
|
||||
- **Restore `public_url` enrichment on `/search`, `/find`, `/person`, `/place`** — `_enrich_assets`'s return value was being discarded, dropping the public URL populated on each asset. Now assigned properly. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
|
||||
- **Surface Immich search errors instead of silently returning `[]`** — `search_smart` / `search_metadata` consolidated into a `_search_items` helper that logs non-200 responses and transport errors, and accepts the alternate `{"assets": [...]}` flat-list shape from older Immich versions. "Always no results" bugs are now diagnosable. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
|
||||
- **Redact Immich search error bodies** before they land in server logs — credentials echoed by authenticating proxies no longer leak into logs. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
|
||||
- **TTL-cache `GET /api/albums/{id}` responses** — 60 s TTL, 32-entry FIFO cap, keyed by `(server_digest, album_id)`. Module-scoped rather than instance-scoped because `ImmichClient` is constructed fresh per request in several places (`api/providers.py`, `services/action_runner.py`, command handlers), so an instance cache would never survive a second caller. Mirrors the existing `_users_cache` pattern. ([3b7808a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b7808a))
|
||||
- **TTL-cache the bucketed shared-links map** — 60 s TTL, keyed by server digest. `/api/shared-links` has no per-album filter, so every `get_shared_links(album_id)` call was already paying for the full server-wide list; now one fetch serves every album until the TTL elapses. ([3b7808a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b7808a))
|
||||
- **Collapse concurrent cache misses to one fetch** — async lock with an under-lock re-check around the album / shared-links populate step, so a burst of parallel commands hitting the same cold key issues one HTTP call instead of N. ([3b7808a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b7808a))
|
||||
- **`use_cache=False` escape hatch on mutation / event-detection paths** — `ImmichActionExecutor.execute` (which diffs the current album state to decide what to add) and `ImmichServiceProvider.poll`'s full-fetch path (where a stale entry would silently delay asset-removal events) explicitly bypass the cache. Non-cached fetches still populate it for subsequent readers. ([3b7808a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b7808a))
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits</summary>
|
||||
|
||||
- [5a232f1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5a232f1) — feat(commands): drop tracker counts from /status *(alexei.dolgolyov)*
|
||||
- [4ff3876](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4ff3876) — fix(commands): /albums honors per-chat scope, disable link previews *(alexei.dolgolyov)*
|
||||
- [3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09) — feat(commands): per-chat album scope derived from notification routing *(alexei.dolgolyov)*
|
||||
- [3b7808a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b7808a) — perf(immich): TTL cache for album bodies and shared-link listings *(alexei.dolgolyov)*
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.3",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
<script lang="ts">
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface LocaleMeta {
|
||||
code: string;
|
||||
name: string; // English name
|
||||
native: string; // Native script
|
||||
rtl?: boolean;
|
||||
}
|
||||
|
||||
const CATALOG: LocaleMeta[] = [
|
||||
{ code: 'en', name: 'English', native: 'English' },
|
||||
{ code: 'ru', name: 'Russian', native: 'Русский' },
|
||||
{ code: 'de', name: 'German', native: 'Deutsch' },
|
||||
{ code: 'fr', name: 'French', native: 'Français' },
|
||||
{ code: 'es', name: 'Spanish', native: 'Español' },
|
||||
{ code: 'it', name: 'Italian', native: 'Italiano' },
|
||||
{ code: 'pt', name: 'Portuguese', native: 'Português' },
|
||||
{ code: 'pl', name: 'Polish', native: 'Polski' },
|
||||
{ code: 'nl', name: 'Dutch', native: 'Nederlands' },
|
||||
{ code: 'sv', name: 'Swedish', native: 'Svenska' },
|
||||
{ code: 'fi', name: 'Finnish', native: 'Suomi' },
|
||||
{ code: 'no', name: 'Norwegian', native: 'Norsk' },
|
||||
{ code: 'da', name: 'Danish', native: 'Dansk' },
|
||||
{ code: 'cs', name: 'Czech', native: 'Čeština' },
|
||||
{ code: 'hu', name: 'Hungarian', native: 'Magyar' },
|
||||
{ code: 'ro', name: 'Romanian', native: 'Română' },
|
||||
{ code: 'el', name: 'Greek', native: 'Ελληνικά' },
|
||||
{ code: 'tr', name: 'Turkish', native: 'Türkçe' },
|
||||
{ code: 'uk', name: 'Ukrainian', native: 'Українська' },
|
||||
{ code: 'be', name: 'Belarusian', native: 'Беларуская' },
|
||||
{ code: 'bg', name: 'Bulgarian', native: 'Български' },
|
||||
{ code: 'sr', name: 'Serbian', native: 'Српски' },
|
||||
{ code: 'ar', name: 'Arabic', native: 'العربية', rtl: true },
|
||||
{ code: 'he', name: 'Hebrew', native: 'עברית', rtl: true },
|
||||
{ code: 'fa', name: 'Persian', native: 'فارسی', rtl: true },
|
||||
{ code: 'zh', name: 'Chinese', native: '中文' },
|
||||
{ code: 'ja', name: 'Japanese', native: '日本語' },
|
||||
{ code: 'ko', name: 'Korean', native: '한국어' },
|
||||
{ code: 'hi', name: 'Hindi', native: 'हिन्दी' },
|
||||
{ code: 'vi', name: 'Vietnamese', native: 'Tiếng Việt' },
|
||||
{ code: 'th', name: 'Thai', native: 'ไทย' },
|
||||
{ code: 'id', name: 'Indonesian', native: 'Bahasa Indonesia' },
|
||||
];
|
||||
|
||||
// Locales that ship with default notification & command templates.
|
||||
const SHIPPED = new Set(['en', 'ru']);
|
||||
|
||||
let {
|
||||
value = $bindable<string>(''),
|
||||
}: {
|
||||
value: string;
|
||||
} = $props();
|
||||
|
||||
// Parse the comma-separated backend string into an ordered array of codes.
|
||||
const codes = $derived.by<string[]>(() => {
|
||||
if (!value) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of value.split(',')) {
|
||||
const c = raw.trim().toLowerCase();
|
||||
if (!c || seen.has(c)) continue;
|
||||
seen.add(c);
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function commit(next: string[]) {
|
||||
// De-dupe (preserve order) and serialise back to the backend format.
|
||||
const seen = new Set<string>();
|
||||
const clean = next.map(c => c.trim().toLowerCase())
|
||||
.filter(c => c && !seen.has(c) && (seen.add(c), true));
|
||||
value = clean.join(',');
|
||||
}
|
||||
|
||||
function meta(code: string): LocaleMeta {
|
||||
return CATALOG.find(l => l.code === code) ?? {
|
||||
code,
|
||||
name: code.toUpperCase(),
|
||||
native: code.toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function remove(code: string) {
|
||||
commit(codes.filter(c => c !== code));
|
||||
}
|
||||
|
||||
function makePrimary(code: string) {
|
||||
commit([code, ...codes.filter(c => c !== code)]);
|
||||
}
|
||||
|
||||
function moveUp(code: string) {
|
||||
const i = codes.indexOf(code);
|
||||
if (i <= 0) return;
|
||||
const next = [...codes];
|
||||
[next[i - 1], next[i]] = [next[i], next[i - 1]];
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function moveDown(code: string) {
|
||||
const i = codes.indexOf(code);
|
||||
if (i < 0 || i >= codes.length - 1) return;
|
||||
const next = [...codes];
|
||||
[next[i], next[i + 1]] = [next[i + 1], next[i]];
|
||||
commit(next);
|
||||
}
|
||||
|
||||
// --- Add flow ----------------------------------------------------------
|
||||
|
||||
let addOpen = $state(false);
|
||||
let addQuery = $state('');
|
||||
let addInputEl = $state<HTMLInputElement | null>(null);
|
||||
let highlightIdx = $state(0);
|
||||
|
||||
// Valid BCP 47-ish: 2–3 letter primary, optional '-' subtag(s) 2-8 chars.
|
||||
const CUSTOM_RE = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
|
||||
|
||||
const selectedSet = $derived(new Set(codes));
|
||||
|
||||
const suggestions = $derived.by(() => {
|
||||
const q = addQuery.trim().toLowerCase();
|
||||
const available = CATALOG.filter(l => !selectedSet.has(l.code));
|
||||
if (!q) return available;
|
||||
return available.filter(l =>
|
||||
l.code.includes(q)
|
||||
|| l.name.toLowerCase().includes(q)
|
||||
|| l.native.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
const canAddCustom = $derived.by(() => {
|
||||
const q = addQuery.trim().toLowerCase();
|
||||
if (!q) return false;
|
||||
if (!CUSTOM_RE.test(q)) return false;
|
||||
if (selectedSet.has(q)) return false;
|
||||
// Skip "custom" entry when it matches an existing catalog entry exactly.
|
||||
if (CATALOG.some(l => l.code === q)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
function openAdd() {
|
||||
addOpen = true;
|
||||
addQuery = '';
|
||||
highlightIdx = 0;
|
||||
requestAnimationFrame(() => addInputEl?.focus());
|
||||
}
|
||||
|
||||
function closeAdd() {
|
||||
addOpen = false;
|
||||
addQuery = '';
|
||||
}
|
||||
|
||||
function addCode(code: string) {
|
||||
const c = code.trim().toLowerCase();
|
||||
if (!c) return;
|
||||
commit([...codes, c]);
|
||||
addQuery = '';
|
||||
highlightIdx = 0;
|
||||
requestAnimationFrame(() => addInputEl?.focus());
|
||||
}
|
||||
|
||||
function onAddKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { closeAdd(); return; }
|
||||
const total = suggestions.length + (canAddCustom ? 1 : 0);
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
highlightIdx = Math.min(highlightIdx + 1, Math.max(0, total - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
highlightIdx = Math.max(highlightIdx - 1, 0);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (highlightIdx < suggestions.length) {
|
||||
addCode(suggestions[highlightIdx].code);
|
||||
} else if (canAddCustom) {
|
||||
addCode(addQuery);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { addQuery; highlightIdx = 0; });
|
||||
|
||||
// --- Drag & drop -------------------------------------------------------
|
||||
|
||||
let dragCode = $state<string | null>(null);
|
||||
let dragOverCode = $state<string | null>(null);
|
||||
|
||||
function onDragStart(e: DragEvent, code: string) {
|
||||
dragCode = code;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', code);
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent, code: string) {
|
||||
if (!dragCode || dragCode === code) return;
|
||||
e.preventDefault();
|
||||
dragOverCode = code;
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent, code: string) {
|
||||
e.preventDefault();
|
||||
if (!dragCode || dragCode === code) return;
|
||||
const from = codes.indexOf(dragCode);
|
||||
const to = codes.indexOf(code);
|
||||
if (from < 0 || to < 0) return;
|
||||
const next = [...codes];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
commit(next);
|
||||
dragCode = null;
|
||||
dragOverCode = null;
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
dragCode = null;
|
||||
dragOverCode = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ls-root">
|
||||
{#if codes.length === 0}
|
||||
<div class="ls-empty">
|
||||
<div class="ls-empty-glyph" aria-hidden="true">A あ Я</div>
|
||||
<p class="ls-empty-text">{t('locales.empty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="ls-list" role="list">
|
||||
{#each codes as code, i (code)}
|
||||
{@const m = meta(code)}
|
||||
{@const isPrimary = i === 0}
|
||||
{@const isShipped = SHIPPED.has(code)}
|
||||
<li
|
||||
class="ls-row"
|
||||
class:ls-row-primary={isPrimary}
|
||||
class:ls-row-dragover={dragOverCode === code}
|
||||
class:ls-row-dragging={dragCode === code}
|
||||
draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, code)}
|
||||
ondragover={(e) => onDragOver(e, code)}
|
||||
ondrop={(e) => onDrop(e, code)}
|
||||
ondragend={onDragEnd}
|
||||
>
|
||||
<span class="ls-rail" aria-hidden="true"></span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ls-handle"
|
||||
aria-label={t('locales.reorder')}
|
||||
title={t('locales.reorder')}
|
||||
tabindex="-1"
|
||||
>
|
||||
<MdiIcon name="mdiDragVertical" size={16} />
|
||||
</button>
|
||||
|
||||
<div class="ls-text">
|
||||
<div class="ls-native" dir={m.rtl ? 'rtl' : 'ltr'} lang={code}>{m.native}</div>
|
||||
<div class="ls-meta">
|
||||
<span class="ls-name">{m.name}</span>
|
||||
<span class="ls-dot" aria-hidden="true">·</span>
|
||||
<span class="ls-code">{code}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ls-badges">
|
||||
{#if isPrimary}
|
||||
<span class="ls-tag ls-tag-primary">
|
||||
<MdiIcon name="mdiStar" size={10} />
|
||||
{t('locales.primary')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if isShipped}
|
||||
<span class="ls-tag ls-tag-shipped" title={t('locales.shippedHint')}>
|
||||
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
|
||||
{t('locales.shipped')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ls-actions">
|
||||
{#if !isPrimary}
|
||||
<button
|
||||
type="button"
|
||||
class="ls-icon-btn"
|
||||
onclick={() => makePrimary(code)}
|
||||
aria-label={t('locales.makePrimary')}
|
||||
title={t('locales.makePrimary')}
|
||||
>
|
||||
<MdiIcon name="mdiStarOutline" size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="ls-icon-btn"
|
||||
onclick={() => moveUp(code)}
|
||||
disabled={i === 0}
|
||||
aria-label={t('locales.moveUp')}
|
||||
title={t('locales.moveUp')}
|
||||
>
|
||||
<MdiIcon name="mdiChevronUp" size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ls-icon-btn"
|
||||
onclick={() => moveDown(code)}
|
||||
disabled={i === codes.length - 1}
|
||||
aria-label={t('locales.moveDown')}
|
||||
title={t('locales.moveDown')}
|
||||
>
|
||||
<MdiIcon name="mdiChevronDown" size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ls-icon-btn ls-icon-danger"
|
||||
onclick={() => remove(code)}
|
||||
disabled={codes.length <= 1}
|
||||
aria-label={t('locales.remove')}
|
||||
title={codes.length <= 1 ? t('locales.removeLast') : t('locales.remove')}
|
||||
>
|
||||
<MdiIcon name="mdiClose" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Add zone -->
|
||||
<div class="ls-add" class:ls-add-open={addOpen}>
|
||||
{#if !addOpen}
|
||||
<button type="button" class="ls-add-trigger" onclick={openAdd}>
|
||||
<MdiIcon name="mdiPlus" size={14} />
|
||||
<span>{t('locales.add')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="ls-add-panel">
|
||||
<div class="ls-add-input-row">
|
||||
<MdiIcon name="mdiMagnify" size={14} />
|
||||
<input
|
||||
bind:this={addInputEl}
|
||||
bind:value={addQuery}
|
||||
onkeydown={onAddKeydown}
|
||||
onblur={() => setTimeout(() => { if (addOpen && !addQuery) closeAdd(); }, 150)}
|
||||
placeholder={t('locales.searchPlaceholder')}
|
||||
class="ls-add-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
/>
|
||||
<button type="button" class="ls-icon-btn" onclick={closeAdd} aria-label={t('common.cancel')}>
|
||||
<MdiIcon name="mdiClose" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ls-add-list" role="listbox">
|
||||
{#each suggestions as s, i (s.code)}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === highlightIdx}
|
||||
class="ls-sugg"
|
||||
class:ls-sugg-hl={i === highlightIdx}
|
||||
onmouseenter={() => highlightIdx = i}
|
||||
onmousedown={(e) => { e.preventDefault(); addCode(s.code); }}
|
||||
>
|
||||
<span class="ls-sugg-native" dir={s.rtl ? 'rtl' : 'ltr'} lang={s.code}>{s.native}</span>
|
||||
<span class="ls-sugg-name">{s.name}</span>
|
||||
<span class="ls-sugg-code">{s.code}</span>
|
||||
{#if SHIPPED.has(s.code)}
|
||||
<span class="ls-sugg-shipped" title={t('locales.shippedHint')}>
|
||||
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if canAddCustom}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={highlightIdx === suggestions.length}
|
||||
class="ls-sugg ls-sugg-custom"
|
||||
class:ls-sugg-hl={highlightIdx === suggestions.length}
|
||||
onmouseenter={() => highlightIdx = suggestions.length}
|
||||
onmousedown={(e) => { e.preventDefault(); addCode(addQuery); }}
|
||||
>
|
||||
<MdiIcon name="mdiPlusCircleOutline" size={14} />
|
||||
<span class="ls-sugg-custom-label">{t('locales.addCustom')}</span>
|
||||
<span class="ls-sugg-code">{addQuery.trim().toLowerCase()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if suggestions.length === 0 && !canAddCustom}
|
||||
<div class="ls-sugg-empty">{t('locales.noSuggestions')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="ls-hint">
|
||||
<MdiIcon name="mdiInformationOutline" size={12} />
|
||||
<span>{t('locales.orderHint')}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ls-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
/* ---- Empty state -------------------------------------------------- */
|
||||
.ls-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
padding: 1rem 1.125rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--color-primary) 4%, transparent) 0%,
|
||||
transparent 60%),
|
||||
var(--color-background);
|
||||
}
|
||||
.ls-empty-glyph {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 300;
|
||||
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.ls-empty-text {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
/* ---- List --------------------------------------------------------- */
|
||||
.ls-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.ls-row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0.75rem 0.625rem 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-background);
|
||||
transition: border-color 0.15s, background 0.15s, transform 0.15s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ls-row:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
|
||||
}
|
||||
.ls-row.ls-row-dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.ls-row.ls-row-dragover {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-background));
|
||||
}
|
||||
.ls-row.ls-row-primary {
|
||||
background:
|
||||
linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
|
||||
transparent 30%),
|
||||
var(--color-background);
|
||||
}
|
||||
|
||||
/* Accent rail — pronounced on primary, near-invisible otherwise */
|
||||
.ls-rail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.ls-row.ls-row-primary .ls-rail {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.ls-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.125rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
opacity: 0.4;
|
||||
cursor: grab;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.ls-row:hover .ls-handle {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.ls-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.ls-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.ls-native {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--color-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
min-width: 0;
|
||||
}
|
||||
.ls-name {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 500;
|
||||
font-size: 0.625rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-dot {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ls-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.05rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.ls-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ls-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-tag-primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground, #fff);
|
||||
}
|
||||
.ls-tag-shipped {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.ls-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
.ls-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.ls-icon-btn:hover:not(:disabled) {
|
||||
background: var(--color-muted);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.ls-icon-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ls-icon-btn.ls-icon-danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, #ef4444 14%, transparent);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* ---- Add zone ----------------------------------------------------- */
|
||||
.ls-add {
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.ls-add-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.ls-add-trigger:hover {
|
||||
border-color: var(--color-primary);
|
||||
border-style: solid;
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
|
||||
}
|
||||
|
||||
.ls-add-panel {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-background);
|
||||
overflow: hidden;
|
||||
animation: ls-pop 0.15s ease-out;
|
||||
}
|
||||
@keyframes ls-pop {
|
||||
from { opacity: 0; transform: translateY(-2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.ls-add-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.ls-add-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-foreground);
|
||||
padding: 0.125rem 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ls-add-list {
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.ls-sugg {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ls-sugg.ls-sugg-hl {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
.ls-sugg-native {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-sugg-name {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ls-sugg-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.05rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.ls-sugg.ls-sugg-hl .ls-sugg-code {
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
|
||||
}
|
||||
.ls-sugg-shipped {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.ls-sugg-custom {
|
||||
border-top: 1px dashed var(--color-border);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.ls-sugg-custom-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ls-sugg-empty {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
/* ---- Hint --------------------------------------------------------- */
|
||||
.ls-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,585 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let {
|
||||
value = $bindable<string>('UTC'),
|
||||
}: {
|
||||
value: string;
|
||||
} = $props();
|
||||
|
||||
// --- Catalog -----------------------------------------------------------
|
||||
|
||||
const timezones = $derived.by<string[]>(() => {
|
||||
try {
|
||||
const intl = Intl as unknown as { supportedValuesOf?: (k: string) => string[] };
|
||||
if (typeof intl.supportedValuesOf === 'function') {
|
||||
return intl.supportedValuesOf('timeZone');
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return ['UTC'];
|
||||
});
|
||||
|
||||
const detectedTz = (() => {
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; }
|
||||
catch { return 'UTC'; }
|
||||
})();
|
||||
|
||||
// --- Live clock --------------------------------------------------------
|
||||
|
||||
let now = $state(new Date());
|
||||
let tickHandle: ReturnType<typeof setInterval> | null = null;
|
||||
onMount(() => {
|
||||
tickHandle = setInterval(() => { now = new Date(); }, 1000);
|
||||
});
|
||||
onDestroy(() => { if (tickHandle) clearInterval(tickHandle); });
|
||||
|
||||
function splitTz(tz: string): { region: string; city: string } {
|
||||
if (!tz || tz === 'UTC' || tz === 'Etc/UTC') return { region: 'UTC', city: 'UTC' };
|
||||
const parts = tz.split('/');
|
||||
if (parts.length === 1) return { region: 'Other', city: parts[0].replace(/_/g, ' ') };
|
||||
const city = parts[parts.length - 1].replace(/_/g, ' ');
|
||||
const region = parts.slice(0, -1).join(' / ').replace(/_/g, ' ');
|
||||
return { region, city };
|
||||
}
|
||||
|
||||
function fmtTime(tz: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
} catch { return '--:--:--'; }
|
||||
}
|
||||
|
||||
function fmtDate(tz: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: tz,
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
}).format(now);
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function fmtOffset(tz: string): string {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
timeZoneName: 'shortOffset',
|
||||
}).formatToParts(now);
|
||||
const off = parts.find(p => p.type === 'timeZoneName')?.value ?? '';
|
||||
return off || 'UTC';
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
// --- Selected state ----------------------------------------------------
|
||||
|
||||
const selected = $derived.by(() => {
|
||||
const s = splitTz(value || 'UTC');
|
||||
return {
|
||||
iana: value || 'UTC',
|
||||
region: s.region,
|
||||
city: s.city,
|
||||
time: fmtTime(value || 'UTC'),
|
||||
date: fmtDate(value || 'UTC'),
|
||||
offset: fmtOffset(value || 'UTC'),
|
||||
};
|
||||
});
|
||||
|
||||
// --- Picker ------------------------------------------------------------
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let highlightIdx = $state(0);
|
||||
let inputEl = $state<HTMLInputElement | null>(null);
|
||||
let panelEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase().replace(/\s+/g, '_');
|
||||
if (!q) return timezones;
|
||||
return timezones.filter(tz => tz.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
// Group filtered tz list by region prefix for visual hierarchy.
|
||||
interface Group { region: string; items: string[] }
|
||||
const groups = $derived.by<Group[]>(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const tz of filtered) {
|
||||
const region = tz.includes('/') ? tz.split('/')[0] : 'Other';
|
||||
if (!map.has(region)) map.set(region, []);
|
||||
map.get(region)!.push(tz);
|
||||
}
|
||||
const REGION_ORDER = ['UTC', 'Europe', 'America', 'Asia', 'Africa', 'Australia', 'Pacific', 'Atlantic', 'Indian', 'Antarctica', 'Arctic', 'Etc', 'Other'];
|
||||
return [...map.entries()]
|
||||
.sort(([a], [b]) => {
|
||||
const ai = REGION_ORDER.indexOf(a);
|
||||
const bi = REGION_ORDER.indexOf(b);
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
})
|
||||
.map(([region, items]) => ({ region, items }));
|
||||
});
|
||||
|
||||
// Flattened index for keyboard navigation.
|
||||
const flat = $derived<string[]>(groups.flatMap(g => g.items));
|
||||
|
||||
function openPicker() {
|
||||
open = true;
|
||||
query = '';
|
||||
highlightIdx = Math.max(0, flat.indexOf(value));
|
||||
requestAnimationFrame(() => {
|
||||
inputEl?.focus();
|
||||
scrollToHighlight();
|
||||
});
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
open = false;
|
||||
query = '';
|
||||
}
|
||||
|
||||
function selectTz(tz: string) {
|
||||
value = tz;
|
||||
closePicker();
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { closePicker(); return; }
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
highlightIdx = Math.min(highlightIdx + 1, flat.length - 1);
|
||||
scrollToHighlight();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
highlightIdx = Math.max(highlightIdx - 1, 0);
|
||||
scrollToHighlight();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (flat[highlightIdx]) selectTz(flat[highlightIdx]);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToHighlight() {
|
||||
requestAnimationFrame(() => {
|
||||
panelEl?.querySelector('.tz-opt-hl')?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => { query; highlightIdx = 0; });
|
||||
|
||||
// Close on outside click
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (!open) return;
|
||||
const target = e.target as Node;
|
||||
if (panelEl && !panelEl.contains(target)) closePicker();
|
||||
}
|
||||
onMount(() => {
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
});
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('mousedown', onDocClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="tz-root">
|
||||
<!-- Selected card -->
|
||||
<button
|
||||
type="button"
|
||||
class="tz-card"
|
||||
class:tz-card-open={open}
|
||||
onclick={() => (open ? closePicker() : openPicker())}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div class="tz-card-left">
|
||||
<div class="tz-region">{selected.region}</div>
|
||||
<div class="tz-city">{selected.city}</div>
|
||||
<div class="tz-sub">
|
||||
<span class="tz-iana">{selected.iana}</span>
|
||||
{#if selected.date}
|
||||
<span class="tz-dot">·</span>
|
||||
<span class="tz-date">{selected.date}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tz-card-right">
|
||||
<div class="tz-clock">{selected.time}</div>
|
||||
<div class="tz-offset">{selected.offset}</div>
|
||||
</div>
|
||||
<span class="tz-chev" aria-hidden="true">
|
||||
<MdiIcon name={open ? 'mdiChevronUp' : 'mdiChevronDown'} size={16} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="tz-panel" bind:this={panelEl} role="listbox">
|
||||
<!-- Search -->
|
||||
<div class="tz-search-row">
|
||||
<MdiIcon name="mdiMagnify" size={14} />
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={t('timezone.searchPlaceholder')}
|
||||
class="tz-search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
/>
|
||||
<kbd class="tz-kbd">ESC</kbd>
|
||||
</div>
|
||||
|
||||
<!-- Quick picks -->
|
||||
{#if !query}
|
||||
<div class="tz-quick">
|
||||
<button
|
||||
type="button"
|
||||
class="tz-quick-btn"
|
||||
class:tz-quick-active={value === detectedTz}
|
||||
onclick={() => selectTz(detectedTz)}
|
||||
>
|
||||
<MdiIcon name="mdiCrosshairsGps" size={12} />
|
||||
<span class="tz-quick-label">{t('timezone.detect')}</span>
|
||||
<span class="tz-quick-val">{detectedTz}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tz-quick-btn"
|
||||
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
|
||||
onclick={() => selectTz('UTC')}
|
||||
>
|
||||
<MdiIcon name="mdiEarth" size={12} />
|
||||
<span class="tz-quick-label">{t('timezone.utc')}</span>
|
||||
<span class="tz-quick-val">UTC+00</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Grouped list -->
|
||||
<div class="tz-list">
|
||||
{#if filtered.length === 0}
|
||||
<div class="tz-empty">{t('timezone.noMatches')}</div>
|
||||
{:else}
|
||||
{#each groups as g (g.region)}
|
||||
<div class="tz-group">
|
||||
<div class="tz-group-head">
|
||||
<span class="tz-group-name">{g.region}</span>
|
||||
<span class="tz-group-count">{g.items.length}</span>
|
||||
</div>
|
||||
{#each g.items as tz (tz)}
|
||||
{@const parts = splitTz(tz)}
|
||||
{@const idx = flat.indexOf(tz)}
|
||||
{@const hl = idx === highlightIdx}
|
||||
{@const sel = tz === value}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={sel}
|
||||
class="tz-opt"
|
||||
class:tz-opt-hl={hl}
|
||||
class:tz-opt-sel={sel}
|
||||
onmouseenter={() => (highlightIdx = idx)}
|
||||
onclick={() => selectTz(tz)}
|
||||
>
|
||||
<span class="tz-opt-city">{parts.city}</span>
|
||||
<span class="tz-opt-iana">{tz}</span>
|
||||
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tz-root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
/* ---- Selected card ------------------------------------------------ */
|
||||
.tz-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
|
||||
transparent 55%),
|
||||
var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.tz-card:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
|
||||
}
|
||||
.tz-card.tz-card-open {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.tz-card-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.tz-region {
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
|
||||
}
|
||||
.tz-city {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tz-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
min-width: 0;
|
||||
}
|
||||
.tz-iana {
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tz-dot { opacity: 0.5; }
|
||||
|
||||
.tz-card-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.tz-clock {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-foreground);
|
||||
line-height: 1;
|
||||
/* Stable width so seconds ticker doesn't shift layout */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tz-offset {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.1rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
}
|
||||
|
||||
.tz-chev {
|
||||
color: var(--color-muted-foreground);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ---- Panel -------------------------------------------------------- */
|
||||
.tz-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.375rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
background: var(--color-card, var(--color-background));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 26rem;
|
||||
animation: tz-pop 0.15s ease-out;
|
||||
}
|
||||
@keyframes tz-pop {
|
||||
from { opacity: 0; transform: translateY(-3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.tz-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.tz-search {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-foreground);
|
||||
padding: 0.125rem 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.tz-kbd {
|
||||
font-size: 0.55rem;
|
||||
font-family: var(--font-mono);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0.2rem;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tz-quick {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tz-quick-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 9999px;
|
||||
background: var(--color-background);
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
.tz-quick-btn:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border));
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.tz-quick-active {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.tz-quick-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.tz-quick-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tz-list {
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.tz-empty {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.tz-group {
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
.tz-group-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.75rem 0.25rem;
|
||||
font-size: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted-foreground);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-card, var(--color-background));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
z-index: 1;
|
||||
}
|
||||
.tz-group-count {
|
||||
font-family: var(--font-mono);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tz-opt {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.tz-opt.tz-opt-hl {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
.tz-opt.tz-opt-sel {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
}
|
||||
.tz-opt-city {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tz-opt.tz-opt-sel .tz-opt-city {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.tz-opt-iana {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tz-opt-offset {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-muted-foreground);
|
||||
padding: 0.1rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tz-opt.tz-opt-hl .tz-opt-offset {
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
|
||||
}
|
||||
</style>
|
||||
@@ -671,13 +671,29 @@
|
||||
"telegram": "Telegram",
|
||||
"webhookSecret": "Webhook Secret",
|
||||
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
|
||||
"cacheTtl": "Media Cache TTL (hours)",
|
||||
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading",
|
||||
"cacheTtl": "URL Cache TTL (hours)",
|
||||
"cacheTtlHint": "How long to keep URL-keyed Telegram file_ids (e.g. shared links). Set 0 to disable TTL. The asset cache uses content hashing (thumbhash) and ignores this.",
|
||||
"cacheMaxEntries": "Cache Max Entries",
|
||||
"cacheMaxEntriesHint": "Upper bound per cache (URL and asset). Oldest entries are evicted first (LRU). Default 5000.",
|
||||
"cacheStats": "Cache contents",
|
||||
"cacheStatsHint": "Size shown is the total bytes of media originally uploaded to Telegram for cached entries — i.e. approximate re-upload bandwidth the cache is saving. The cache file itself is only a few KB; the media lives on Telegram's servers.",
|
||||
"cacheStatsUrl": "URL cache",
|
||||
"cacheStatsAsset": "Asset cache",
|
||||
"cacheStatsEntries": "entries",
|
||||
"cacheStatsEmpty": "empty",
|
||||
"cacheStatsOldest": "oldest",
|
||||
"cacheStatsNewest": "newest",
|
||||
"clearCache": "Clear Media Cache",
|
||||
"clearCacheHint": "Delete cached Telegram file_ids. Next send will re-upload media.",
|
||||
"clearCacheConfirmTitle": "Clear Telegram cache?",
|
||||
"clearCacheConfirm": "This removes all cached Telegram file_ids. Subsequent notifications will re-upload media, which may take longer and use more bandwidth.",
|
||||
"clearCacheConfirmBtn": "Clear cache",
|
||||
"clearCacheDone": "Telegram cache cleared",
|
||||
"timezone": "Timezone",
|
||||
"timezoneHint": "IANA timezone (e.g. UTC, Europe/Warsaw, America/New_York). Used to interpret HH:MM fields like quiet hours.",
|
||||
"locales": "Template Languages",
|
||||
"supportedLocales": "Supported Locales",
|
||||
"supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)",
|
||||
"supportedLocalesHint": "Languages available when authoring notification and command templates. Built-in defaults ship for English and Russian; other languages start empty.",
|
||||
"saved": "Settings saved"
|
||||
},
|
||||
"hints": {
|
||||
@@ -813,6 +829,29 @@
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details"
|
||||
},
|
||||
"timezone": {
|
||||
"searchPlaceholder": "Search cities or IANA codes…",
|
||||
"detect": "Detect",
|
||||
"utc": "UTC",
|
||||
"noMatches": "No timezones match"
|
||||
},
|
||||
"locales": {
|
||||
"empty": "No languages selected. Add one below to start authoring templates.",
|
||||
"add": "Add language",
|
||||
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
|
||||
"addCustom": "Add custom code",
|
||||
"noSuggestions": "No matches. Type a valid locale code (2–3 letters).",
|
||||
"primary": "Primary",
|
||||
"shipped": "Built-in",
|
||||
"shippedHint": "Default notification & command templates ship for this language.",
|
||||
"makePrimary": "Make primary",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"remove": "Remove",
|
||||
"removeLast": "At least one language is required",
|
||||
"reorder": "Drag to reorder",
|
||||
"orderHint": "First language is the primary fallback when a translation is missing. Drag to reorder."
|
||||
},
|
||||
"snack": {
|
||||
"eventsCleared": "{count} event(s) cleared",
|
||||
"providerSaved": "Provider saved",
|
||||
|
||||
@@ -671,13 +671,29 @@
|
||||
"telegram": "Telegram",
|
||||
"webhookSecret": "Секрет вебхука",
|
||||
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
|
||||
"cacheTtl": "TTL кэша медиа (часы)",
|
||||
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
|
||||
"cacheTtl": "TTL URL-кэша (часы)",
|
||||
"cacheTtlHint": "Сколько хранить Telegram file_id, привязанные к URL (напр. публичные ссылки). 0 — отключить TTL. Кэш ассетов использует хэширование содержимого (thumbhash) и не зависит от этой настройки.",
|
||||
"cacheMaxEntries": "Макс. записей в кэше",
|
||||
"cacheMaxEntriesHint": "Верхний предел записей в каждом кэше (URL и ассеты). При превышении удаляются самые старые (LRU). По умолчанию 5000.",
|
||||
"cacheStats": "Содержимое кэша",
|
||||
"cacheStatsHint": "Показываемый размер — это суммарный объём медиа, который был изначально загружен в Telegram для закэшированных записей, т.е. приблизительный объём повторных загрузок, который экономит кэш. Сам файл кэша занимает лишь несколько КБ; медиа хранится на серверах Telegram.",
|
||||
"cacheStatsUrl": "Кэш URL",
|
||||
"cacheStatsAsset": "Кэш ассетов",
|
||||
"cacheStatsEntries": "записей",
|
||||
"cacheStatsEmpty": "пусто",
|
||||
"cacheStatsOldest": "самая старая",
|
||||
"cacheStatsNewest": "самая свежая",
|
||||
"clearCache": "Очистить кэш медиа",
|
||||
"clearCacheHint": "Удалить кэшированные Telegram file_id. При следующей отправке медиа будут загружены заново.",
|
||||
"clearCacheConfirmTitle": "Очистить кэш Telegram?",
|
||||
"clearCacheConfirm": "Это удалит все кэшированные Telegram file_id. Следующие уведомления будут повторно загружать медиа, что может занять больше времени и трафика.",
|
||||
"clearCacheConfirmBtn": "Очистить кэш",
|
||||
"clearCacheDone": "Кэш Telegram очищен",
|
||||
"timezone": "Часовой пояс",
|
||||
"timezoneHint": "Часовой пояс IANA (например UTC, Europe/Warsaw, America/New_York). Используется для интерпретации полей HH:MM, таких как тихие часы.",
|
||||
"locales": "Языки шаблонов",
|
||||
"supportedLocales": "Поддерживаемые локали",
|
||||
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)",
|
||||
"supportedLocalesHint": "Языки, доступные для редактирования шаблонов уведомлений и команд. Встроенные шаблоны поставляются для английского и русского; другие языки начинают с пустых.",
|
||||
"saved": "Настройки сохранены"
|
||||
},
|
||||
"hints": {
|
||||
@@ -813,6 +829,29 @@
|
||||
"showDetails": "Показать детали",
|
||||
"hideDetails": "Скрыть детали"
|
||||
},
|
||||
"timezone": {
|
||||
"searchPlaceholder": "Поиск по городам или IANA-кодам…",
|
||||
"detect": "Определить",
|
||||
"utc": "UTC",
|
||||
"noMatches": "Нет совпадений"
|
||||
},
|
||||
"locales": {
|
||||
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
|
||||
"add": "Добавить язык",
|
||||
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
|
||||
"addCustom": "Добавить свой код",
|
||||
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
|
||||
"primary": "Основной",
|
||||
"shipped": "Встроенный",
|
||||
"shippedHint": "Для этого языка есть встроенные шаблоны уведомлений и команд.",
|
||||
"makePrimary": "Сделать основным",
|
||||
"moveUp": "Выше",
|
||||
"moveDown": "Ниже",
|
||||
"remove": "Удалить",
|
||||
"removeLast": "Должен быть хотя бы один язык",
|
||||
"reorder": "Перетащите для изменения порядка",
|
||||
"orderHint": "Первый язык используется как основной при отсутствии перевода. Перетаскивайте, чтобы изменить порядок."
|
||||
},
|
||||
"snack": {
|
||||
"eventsCleared": "Очищено событий: {count}",
|
||||
"providerSaved": "Провайдер сохранён",
|
||||
|
||||
@@ -226,24 +226,15 @@
|
||||
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||
]);
|
||||
|
||||
// "More" panel items — everything not in the bottom bar
|
||||
const mobileMoreItems = $derived<NavItem[]>([
|
||||
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' },
|
||||
{ href: '/bots?tab=telegram', key: 'nav.bots', icon: 'mdiRobot' },
|
||||
{ href: '/actions', key: 'nav.actions', icon: 'mdiPlayCircleOutline' },
|
||||
{ href: '/tracking-configs', key: 'nav.configs', icon: 'mdiCog' },
|
||||
{ href: '/template-configs', key: 'nav.templates', icon: 'mdiFileDocumentEdit' },
|
||||
{ href: '/command-configs', key: 'nav.configs', icon: 'mdiConsoleLine' },
|
||||
{ href: '/command-template-configs', key: 'nav.templates', icon: 'mdiCodeBracesBox' },
|
||||
...(auth.isAdmin ? [
|
||||
{ href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' },
|
||||
{ href: '/settings/backup', key: 'nav.backup', icon: 'mdiBackupRestore' },
|
||||
{ href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' },
|
||||
] : []),
|
||||
]);
|
||||
|
||||
// "More" panel mirrors the full desktop sidebar tree so every subnode is
|
||||
// reachable on mobile (previously it was a flat hand-picked list that
|
||||
// hid all target types, bot channels, and several nested pages).
|
||||
let mobileMoreOpen = $state(false);
|
||||
|
||||
function closeMobileMore() {
|
||||
mobileMoreOpen = false;
|
||||
}
|
||||
|
||||
const isAuthPage = $derived(
|
||||
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
||||
);
|
||||
@@ -538,7 +529,7 @@
|
||||
</aside>
|
||||
|
||||
<!-- Mobile bottom nav -->
|
||||
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0; backdrop-filter: blur(12px);">
|
||||
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 60; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0 calc(0.375rem + env(safe-area-inset-bottom, 0px)); backdrop-filter: blur(12px);">
|
||||
{#each mobileNavItems as item}
|
||||
<a href={item.href} aria-label={t(item.key)}
|
||||
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
|
||||
@@ -558,40 +549,69 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile "More" panel -->
|
||||
<!-- Mobile "More" panel — mirrors the full desktop nav tree -->
|
||||
{#if mobileMoreOpen}
|
||||
<div class="mobile-more-backdrop" style="position: fixed; inset: 0; z-index: 49; background: rgba(0,0,0,0.4); backdrop-filter: blur(2px);"
|
||||
onclick={() => mobileMoreOpen = false} role="presentation"></div>
|
||||
<div class="mobile-more-panel" style="position: fixed; bottom: 3.25rem; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: 60vh; overflow-y: auto;"
|
||||
onclick={closeMobileMore} role="presentation"></div>
|
||||
<div class="mobile-more-panel" style="position: fixed; bottom: calc(3rem + env(safe-area-inset-bottom, 0px)); left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: calc(70vh - env(safe-area-inset-bottom, 0px)); overflow-y: auto;"
|
||||
transition:slide={{ duration: 200, easing: cubicOut }}>
|
||||
{#if allProviders.length >= 1}
|
||||
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
|
||||
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each mobileMoreItems as item}
|
||||
<a href={item.href}
|
||||
onclick={() => mobileMoreOpen = false}
|
||||
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200"
|
||||
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(item.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
|
||||
>
|
||||
<MdiIcon name={item.icon} size={20} />
|
||||
<span class="text-xs text-center leading-tight">{t(item.key)}</span>
|
||||
</a>
|
||||
<div class="space-y-3">
|
||||
{#each navEntries as entry}
|
||||
{#if isGroup(entry)}
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5 px-1 pb-1.5 text-[0.65rem] font-semibold uppercase tracking-wider"
|
||||
style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name={entry.icon} size={13} />
|
||||
<span>{t(entry.key)}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each entry.children as child}
|
||||
<a href={child.href} onclick={closeMobileMore}
|
||||
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200 relative"
|
||||
style="color: {isActive(child.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(child.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
|
||||
>
|
||||
<MdiIcon name={child.icon} size={20} />
|
||||
<span class="text-xs text-center leading-tight">{t(child.key)}</span>
|
||||
{#if child.countKey && navCounts[child.countKey]}
|
||||
<span class="nav-badge-sm" style="position: absolute; top: 0.25rem; right: 0.25rem;">{navCounts[child.countKey]}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<a href={entry.href} onclick={closeMobileMore}
|
||||
class="flex items-center gap-2 p-3 rounded-lg transition-all duration-200 relative"
|
||||
style="color: {isActive(entry.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(entry.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
|
||||
>
|
||||
<MdiIcon name={entry.icon} size={18} />
|
||||
<span class="text-sm flex-1">{t(entry.key)}</span>
|
||||
{#if entry.countKey && navCounts[entry.countKey]}
|
||||
<span class="nav-badge">{navCounts[entry.countKey]}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
<button onclick={() => { mobileMoreOpen = false; logout(); }}
|
||||
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200"
|
||||
style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiLogout" size={20} />
|
||||
<span class="text-xs text-center leading-tight">{t('nav.logout')}</span>
|
||||
</button>
|
||||
<div class="pt-2" style="border-top: 1px solid var(--color-border);">
|
||||
<button onclick={() => { closeMobileMore(); logout(); }}
|
||||
class="flex items-center gap-2 p-3 w-full rounded-lg transition-all duration-200"
|
||||
style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiLogout" size={18} />
|
||||
<span class="text-sm">{t('nav.logout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 overflow-auto pb-16 md:pb-0">
|
||||
<main class="flex-1 overflow-auto md:pb-0"
|
||||
style="padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px));">
|
||||
{#key page.url.pathname}
|
||||
<div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}>
|
||||
{@render children()}
|
||||
@@ -611,19 +631,22 @@
|
||||
<!-- Password change modal -->
|
||||
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}>
|
||||
<form onsubmit={changePassword} class="space-y-3">
|
||||
<input type="text" name="username" autocomplete="username" value={auth.user?.username ?? ''}
|
||||
readonly aria-hidden="true" tabindex="-1"
|
||||
style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;" />
|
||||
<div>
|
||||
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
|
||||
<input id="pwd-current" type="password" bind:value={pwdCurrent} required
|
||||
<input id="pwd-current" type="password" autocomplete="current-password" bind:value={pwdCurrent} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||
<input id="pwd-new" type="password" bind:value={pwdNew} required minlength="8"
|
||||
<input id="pwd-new" type="password" autocomplete="new-password" bind:value={pwdNew} required minlength="8"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="pwd-confirm" class="block text-sm font-medium mb-1">{t('auth.confirmPassword')}</label>
|
||||
<input id="pwd-confirm" type="password" bind:value={pwdConfirm} required minlength="8"
|
||||
<input id="pwd-confirm" type="password" autocomplete="new-password" bind:value={pwdConfirm} required minlength="8"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{#if pwdMsg}
|
||||
|
||||
@@ -117,6 +117,14 @@
|
||||
return form.slots[slotName]?.[activeLocale] || '';
|
||||
}
|
||||
|
||||
/** Resolve variable reference for a slot, preferring provider-specific over shared. */
|
||||
function getVarsFor(slotName: string) {
|
||||
const providerVars = varsRef[form.provider_type];
|
||||
return providerVars?.[slotName] ?? varsRef[slotName];
|
||||
}
|
||||
|
||||
let modalVars = $derived(showVarsFor ? getVarsFor(showVarsFor) : null);
|
||||
|
||||
/** Set slot template for current locale (immutable update). */
|
||||
function setSlotValue(slotName: string, value: string) {
|
||||
form.slots = {
|
||||
@@ -369,7 +377,7 @@
|
||||
{t('templateConfig.preview')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if varsRef[slot.name]}
|
||||
{#if getVarsFor(slot.name)}
|
||||
<button type="button" onclick={() => showVarsFor = slot.name}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
||||
{/if}
|
||||
@@ -385,7 +393,7 @@
|
||||
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
|
||||
rows={3}
|
||||
errorLine={slotErrorLines[slot.name] || null}
|
||||
variables={varsRef[slot.name] || undefined}
|
||||
variables={getVarsFor(slot.name) || undefined}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -468,11 +476,11 @@
|
||||
|
||||
<!-- Variables reference modal -->
|
||||
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
|
||||
{#if showVarsFor && varsRef[showVarsFor]}
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{varsRef[showVarsFor].description}</p>
|
||||
{#if showVarsFor && modalVars}
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{modalVars.description}</p>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
|
||||
{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]}
|
||||
{#each Object.entries(modalVars.variables || {}) as [name, desc]}
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
|
||||
@@ -484,11 +492,19 @@
|
||||
['album_fields', 'album', 'Album fields'],
|
||||
['command_fields', 'cmd', 'Command fields'],
|
||||
['event_fields', 'event', 'Event fields'],
|
||||
['repo_fields', 'repo', 'Repository fields'],
|
||||
['issue_fields', 'issue', 'Issue fields'],
|
||||
['pr_fields', 'pr', 'Pull request fields'],
|
||||
['commit_fields', 'c', 'Commit fields'],
|
||||
['board_fields', 'board', 'Board fields'],
|
||||
['card_fields', 'card', 'Card fields'],
|
||||
['list_fields', 'lst', 'List fields'],
|
||||
['device_fields', 'd', 'Device fields'],
|
||||
] as [fieldKey, prefix, title]}
|
||||
{#if varsRef[showVarsFor][fieldKey]}
|
||||
{#if modalVars[fieldKey]}
|
||||
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
|
||||
<p class="text-xs font-medium mb-1">{title} <span class="font-normal text-[var(--color-muted-foreground)]">(use {prefix}.field)</span>:</p>
|
||||
{#each Object.entries(varsRef[showVarsFor][fieldKey]) as [name, desc]}
|
||||
{#each Object.entries(modalVars[fieldKey]) as [name, desc]}
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + prefix + '.' + name + ' }}'}</code>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
|
||||
|
||||
@@ -9,26 +9,66 @@
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
|
||||
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
interface CacheBucketStats {
|
||||
count: number;
|
||||
total_size_bytes: number;
|
||||
oldest: string | null;
|
||||
newest: string | null;
|
||||
}
|
||||
interface CacheStats {
|
||||
url: CacheBucketStats;
|
||||
asset: CacheBucketStats;
|
||||
}
|
||||
|
||||
let loaded = $state(false);
|
||||
let saving = $state(false);
|
||||
let clearingCache = $state(false);
|
||||
let confirmClearCache = $state(false);
|
||||
let error = $state('');
|
||||
let settings = $state({
|
||||
external_url: '',
|
||||
telegram_webhook_secret: '',
|
||||
telegram_cache_ttl_hours: '48',
|
||||
telegram_cache_ttl_hours: '720',
|
||||
telegram_asset_cache_max_entries: '5000',
|
||||
supported_locales: 'en,ru',
|
||||
timezone: 'UTC',
|
||||
});
|
||||
let cacheStats = $state<CacheStats | null>(null);
|
||||
|
||||
async function loadCacheStats() {
|
||||
try {
|
||||
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
|
||||
} catch { cacheStats = null; }
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
settings = await api('/settings');
|
||||
await loadCacheStats();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { loaded = true; }
|
||||
});
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
let v = bytes;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatTs(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||
return isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true; error = '';
|
||||
try {
|
||||
@@ -37,6 +77,17 @@
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
saving = false;
|
||||
}
|
||||
|
||||
async function clearTelegramCache() {
|
||||
confirmClearCache = false;
|
||||
clearingCache = true;
|
||||
try {
|
||||
await api('/settings/telegram-cache/clear', { method: 'POST' });
|
||||
snackSuccess(t('settings.clearCacheDone'));
|
||||
await loadCacheStats();
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
clearingCache = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('settings.title')} description={t('settings.description')} />
|
||||
@@ -59,9 +110,8 @@
|
||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
|
||||
<input bind:value={settings.timezone} placeholder="UTC"
|
||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
<label class="block text-xs font-medium mb-2">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
|
||||
<TimezoneSelector bind:value={settings.timezone} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -75,14 +125,68 @@
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
|
||||
<input bind:value={settings.telegram_webhook_secret} type="password" placeholder={t('providers.optional')}
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
<form onsubmit={(e) => e.preventDefault()} autocomplete="off">
|
||||
<input bind:value={settings.telegram_webhook_secret} type="password" autocomplete="off" placeholder={t('providers.optional')}
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
|
||||
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="1" max="720"
|
||||
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="0" max="8760"
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheMaxEntries')}<Hint text={t('settings.cacheMaxEntriesHint')} /></label>
|
||||
<input bind:value={settings.telegram_asset_cache_max_entries} type="number" min="100" max="100000"
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-[var(--color-border)]">
|
||||
<div class="text-xs font-medium mb-2 flex items-center" style="color: var(--color-muted-foreground);">
|
||||
{t('settings.cacheStats')}<Hint text={t('settings.cacheStatsHint')} />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-3">
|
||||
{#each [
|
||||
{ label: t('settings.cacheStatsUrl'), data: cacheStats?.url },
|
||||
{ label: t('settings.cacheStatsAsset'), data: cacheStats?.asset },
|
||||
] as bucket}
|
||||
<div class="px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)] text-xs">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="font-medium">{bucket.label}</span>
|
||||
{#if bucket.data && bucket.data.count > 0}
|
||||
<span>
|
||||
<span class="font-mono">{bucket.data.count}</span>
|
||||
<span style="color: var(--color-muted-foreground);"> {t('settings.cacheStatsEntries')}</span>
|
||||
{#if bucket.data.total_size_bytes > 0}
|
||||
<span style="color: var(--color-muted-foreground);"> · </span>
|
||||
<span class="font-mono">{formatBytes(bucket.data.total_size_bytes)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span style="color: var(--color-muted-foreground);">{t('settings.cacheStatsEmpty')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if bucket.data && bucket.data.count > 0 && (bucket.data.oldest || bucket.data.newest)}
|
||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5" style="color: var(--color-muted-foreground);">
|
||||
{#if bucket.data.oldest}
|
||||
<span>{t('settings.cacheStatsOldest')}: <span class="font-mono">{formatTs(bucket.data.oldest)}</span></span>
|
||||
{/if}
|
||||
{#if bucket.data.newest}
|
||||
<span>{t('settings.cacheStatsNewest')}: <span class="font-mono">{formatTs(bucket.data.newest)}</span></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" onclick={() => confirmClearCache = true} disabled={clearingCache}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-background)] hover:bg-[var(--color-muted)] disabled:opacity-50">
|
||||
<MdiIcon name="mdiDeleteSweep" size={16} />
|
||||
{clearingCache ? t('common.loading') : t('settings.clearCache')}
|
||||
</button>
|
||||
<span class="text-xs" style="color: var(--color-muted-foreground);">{t('settings.clearCacheHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -94,9 +198,8 @@
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
||||
<input bind:value={settings.supported_locales} placeholder="en,ru,de,fr"
|
||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
<label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
||||
<LocaleSelector bind:value={settings.supported_locales} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -105,4 +208,12 @@
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfirmModal open={confirmClearCache}
|
||||
title={t('settings.clearCacheConfirmTitle')}
|
||||
message={t('settings.clearCacheConfirm')}
|
||||
confirmLabel={t('settings.clearCacheConfirmBtn')}
|
||||
confirmIcon="mdiDeleteSweep"
|
||||
onconfirm={clearTelegramCache}
|
||||
oncancel={() => confirmClearCache = false} />
|
||||
{/if}
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-core"
|
||||
version = "0.2.3"
|
||||
version = "0.3.1"
|
||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -46,6 +46,7 @@ from .receiver import (
|
||||
from .telegram.cache import TelegramFileCache
|
||||
from .telegram.client import TelegramClient
|
||||
from .telegram.media import (
|
||||
build_telegram_asset_entry,
|
||||
extract_asset_id_from_url,
|
||||
is_asset_cache_key,
|
||||
is_asset_id,
|
||||
@@ -266,23 +267,19 @@ class NotificationDispatcher:
|
||||
# Prefer internal URL for fetching (LAN speed vs public internet)
|
||||
internal_url = (target.provider_internal_url or "").rstrip("/")
|
||||
external_url = (target.provider_external_url or "").rstrip("/")
|
||||
provider_urls = [u for u in (internal_url, external_url) if u]
|
||||
assets = []
|
||||
media_assets: list[Any] = [] # aligned with `assets` for preload
|
||||
for asset in event.added_assets[:max_media]:
|
||||
url = asset.preview_url or asset.thumbnail_url or asset.full_url
|
||||
if url:
|
||||
# Rewrite external URL to internal for faster LAN fetching
|
||||
if internal_url and external_url and url.startswith(external_url):
|
||||
url = internal_url + url[len(external_url):]
|
||||
asset_type = "video" if asset.type.value == "video" else "photo"
|
||||
asset_headers = {}
|
||||
if target.provider_api_key and any(url.startswith(u) for u in provider_urls):
|
||||
asset_headers["x-api-key"] = target.provider_api_key
|
||||
asset_entry: dict[str, Any] = {"url": url, "type": asset_type, "headers": asset_headers}
|
||||
# Pass explicit cache_key if set by provider (e.g. Google Photos)
|
||||
if asset.extra.get("cache_key"):
|
||||
asset_entry["cache_key"] = asset.extra["cache_key"]
|
||||
asset_entry = build_telegram_asset_entry(
|
||||
url=url or "",
|
||||
media_type=asset.type.value,
|
||||
api_key=target.provider_api_key,
|
||||
internal_url=internal_url,
|
||||
external_url=external_url,
|
||||
cache_key=asset.extra.get("cache_key"),
|
||||
)
|
||||
if asset_entry is not None:
|
||||
assets.append(asset_entry)
|
||||
media_assets.append(asset)
|
||||
|
||||
@@ -294,10 +291,24 @@ class NotificationDispatcher:
|
||||
await self._preload_asset_data(assets, media_assets, session, max_size)
|
||||
default_message = self._render_message(event, target, target.locale)
|
||||
|
||||
# Asset cache (when in thumbhash mode) invalidates entries when the
|
||||
# asset's visual content changes. The resolver maps asset id → its
|
||||
# current thumbhash. Providers that expose thumbhash put it in
|
||||
# ``asset.extra["thumbhash"]`` (currently Immich).
|
||||
thumbhash_map = {
|
||||
asset.id: asset.extra.get("thumbhash")
|
||||
for asset in event.added_assets
|
||||
if asset.extra.get("thumbhash")
|
||||
}
|
||||
thumbhash_resolver = (
|
||||
(lambda key: thumbhash_map.get(key)) if thumbhash_map else None
|
||||
)
|
||||
|
||||
client = TelegramClient(
|
||||
session, bot_token,
|
||||
url_cache=self._url_cache,
|
||||
asset_cache=self._asset_cache,
|
||||
thumbhash_resolver=thumbhash_resolver,
|
||||
)
|
||||
|
||||
for receiver in target.receivers:
|
||||
|
||||
@@ -11,56 +11,69 @@ from notify_bridge_core.storage import StorageBackend
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
|
||||
DEFAULT_MAX_ENTRIES = 5000
|
||||
|
||||
|
||||
class TelegramFileCache:
|
||||
"""Cache for Telegram file_ids to avoid re-uploading media.
|
||||
|
||||
Supports two validation modes:
|
||||
- TTL mode (default): entries expire after a configured time-to-live
|
||||
- Thumbhash mode: entries validated by comparing stored thumbhash with current
|
||||
"""
|
||||
Two complementary invalidation strategies, usable together or separately:
|
||||
- TTL: entries expire after ``ttl_seconds``. Set to 0 to disable TTL
|
||||
(cache essentially forever, subject only to the size cap).
|
||||
- Thumbhash mode: entries are validated on read by comparing the stored
|
||||
thumbhash with the one the caller supplies; a mismatch drops the entry.
|
||||
Intended for content-addressable assets (e.g. Immich) where re-uploads
|
||||
should be triggered by visual change, not elapsed time.
|
||||
|
||||
THUMBHASH_MAX_ENTRIES = 2000
|
||||
``max_entries`` always applies as an LRU size cap (by ``cached_at``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend: StorageBackend,
|
||||
ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
|
||||
use_thumbhash: bool = False,
|
||||
max_entries: int = DEFAULT_MAX_ENTRIES,
|
||||
) -> None:
|
||||
self._backend = backend
|
||||
self._data: dict[str, Any] | None = None
|
||||
self._ttl_seconds = ttl_seconds
|
||||
self._use_thumbhash = use_thumbhash
|
||||
self._max_entries = max_entries
|
||||
|
||||
async def async_load(self) -> None:
|
||||
self._data = await self._backend.load() or {"files": {}}
|
||||
await self._cleanup_expired()
|
||||
|
||||
async def _cleanup_expired(self) -> None:
|
||||
if self._use_thumbhash:
|
||||
files = self._data.get("files", {}) if self._data else {}
|
||||
if len(files) > self.THUMBHASH_MAX_ENTRIES:
|
||||
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
|
||||
for key in sorted_keys[: len(files) - self.THUMBHASH_MAX_ENTRIES]:
|
||||
del files[key]
|
||||
await self._backend.save(self._data)
|
||||
return
|
||||
|
||||
if not self._data or "files" not in self._data:
|
||||
return
|
||||
files = self._data["files"]
|
||||
changed = False
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expired = [
|
||||
url for url, entry in self._data["files"].items()
|
||||
if entry.get("cached_at") and
|
||||
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
|
||||
]
|
||||
|
||||
if expired:
|
||||
# TTL sweep — only when TTL validation is active (i.e. no thumbhash
|
||||
# mode and a positive TTL). In thumbhash mode we rely entirely on
|
||||
# content validation; in "TTL disabled" mode (ttl_seconds <= 0) we
|
||||
# cache forever, subject only to the size cap.
|
||||
if not self._use_thumbhash and self._ttl_seconds > 0:
|
||||
now = datetime.now(timezone.utc)
|
||||
expired = [
|
||||
url for url, entry in files.items()
|
||||
if entry.get("cached_at") and
|
||||
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
|
||||
]
|
||||
for key in expired:
|
||||
del self._data["files"][key]
|
||||
del files[key]
|
||||
changed = True
|
||||
|
||||
# LRU cap — always enforced. Evicts oldest-cached entries first.
|
||||
if self._max_entries > 0 and len(files) > self._max_entries:
|
||||
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
|
||||
for key in sorted_keys[: len(files) - self._max_entries]:
|
||||
del files[key]
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await self._backend.save(self._data)
|
||||
|
||||
def get(self, key: str, thumbhash: str | None = None) -> dict[str, Any] | None:
|
||||
@@ -77,7 +90,7 @@ class TelegramFileCache:
|
||||
if stored and stored != thumbhash:
|
||||
del self._data["files"][key]
|
||||
return None
|
||||
else:
|
||||
elif self._ttl_seconds > 0:
|
||||
cached_at_str = entry.get("cached_at")
|
||||
if cached_at_str:
|
||||
age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds()
|
||||
@@ -152,3 +165,32 @@ class TelegramFileCache:
|
||||
async def async_remove(self) -> None:
|
||||
await self._backend.remove()
|
||||
self._data = None
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
"""Return summary stats about the current cache contents.
|
||||
|
||||
Includes the number of cached entries, total tracked size in bytes
|
||||
(only counts entries with a recorded ``size``), and the oldest /
|
||||
newest ``cached_at`` timestamps (ISO strings, or ``None`` if empty).
|
||||
"""
|
||||
files = self._data.get("files", {}) if self._data else {}
|
||||
count = len(files)
|
||||
total_size = 0
|
||||
oldest: str | None = None
|
||||
newest: str | None = None
|
||||
for entry in files.values():
|
||||
size = entry.get("size")
|
||||
if isinstance(size, int):
|
||||
total_size += size
|
||||
cached_at = entry.get("cached_at")
|
||||
if cached_at:
|
||||
if oldest is None or cached_at < oldest:
|
||||
oldest = cached_at
|
||||
if newest is None or cached_at > newest:
|
||||
newest = cached_at
|
||||
return {
|
||||
"count": count,
|
||||
"total_size_bytes": total_size,
|
||||
"oldest": oldest,
|
||||
"newest": newest,
|
||||
}
|
||||
|
||||
@@ -89,6 +89,18 @@ class TelegramClient:
|
||||
self, url: str | None, cache_key: str | None = None,
|
||||
) -> tuple[TelegramFileCache | None, str | None, str | None]:
|
||||
if cache_key:
|
||||
# Route asset-UUID cache keys to the asset cache so single-item
|
||||
# sends hit the same cache the media-group path uses. Without
|
||||
# this, a command returning one photo stored file_ids in the
|
||||
# URL cache and a command returning multiple stored them in
|
||||
# the asset cache — repeated sends never hit.
|
||||
if is_asset_cache_key(cache_key):
|
||||
bare_id = asset_id_from_cache_key(cache_key)
|
||||
thumbhash = (
|
||||
self._thumbhash_resolver(bare_id)
|
||||
if self._thumbhash_resolver else None
|
||||
)
|
||||
return self._asset_cache, cache_key, thumbhash
|
||||
return self._url_cache, cache_key, None
|
||||
if url:
|
||||
if is_asset_id(url):
|
||||
@@ -217,7 +229,7 @@ class TelegramClient:
|
||||
|
||||
typing_task = None
|
||||
if chat_action:
|
||||
typing_task = self._start_typing_indicator(chat_id, chat_action)
|
||||
typing_task = self.start_chat_action_keepalive(chat_id, chat_action)
|
||||
|
||||
try:
|
||||
if len(assets) == 1 and assets[0].get("type") == "photo":
|
||||
@@ -328,7 +340,13 @@ class TelegramClient:
|
||||
except aiohttp.ClientError:
|
||||
return False
|
||||
|
||||
def _start_typing_indicator(self, chat_id: str, action: str = "typing") -> asyncio.Task:
|
||||
def start_chat_action_keepalive(self, chat_id: str, action: str = "typing") -> asyncio.Task:
|
||||
"""Repeatedly post ``action`` every 4s until the returned task is cancelled.
|
||||
|
||||
Telegram chat actions expire after ~5s, so callers that want the hint
|
||||
to persist through longer work (fetching assets, multi-chunk uploads)
|
||||
need a keep-alive. Cancel the task in a ``finally`` to stop it.
|
||||
"""
|
||||
async def action_loop() -> None:
|
||||
try:
|
||||
while True:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Final
|
||||
from typing import Any, Final
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Telegram constants
|
||||
@@ -52,6 +52,65 @@ def extract_asset_id_from_url(url: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def build_telegram_asset_entry(
|
||||
*,
|
||||
url: str,
|
||||
media_type: str,
|
||||
api_key: str | None = None,
|
||||
internal_url: str = "",
|
||||
external_url: str = "",
|
||||
cache_key: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Build a ``TelegramClient.send_notification`` asset dict from raw fields.
|
||||
|
||||
Shared by the notification dispatcher and provider command handlers so
|
||||
both paths agree on media typing, URL rewriting, and auth headers. In
|
||||
particular: video assets MUST be typed ``"video"`` and point at a real
|
||||
video endpoint (e.g. Immich ``/video/playback``) — if they are sent as
|
||||
``"photo"`` pointing at a thumbnail URL, Telegram delivers a still image
|
||||
for every video in a media group and the user sees a dead poster frame
|
||||
instead of a playable clip.
|
||||
|
||||
Args:
|
||||
url: Source URL for the asset bytes. Prefer a transcoded/preview
|
||||
URL for videos (``/video/playback``) and a preview-sized
|
||||
thumbnail for photos.
|
||||
media_type: Case-insensitive type token. Accepts ``"video"``/
|
||||
``"VIDEO"``/``MediaType.VIDEO`` or any photo-like string.
|
||||
api_key: Optional API key. Attached as ``x-api-key`` iff the URL is
|
||||
served by one of the provider hosts in ``internal_url`` /
|
||||
``external_url`` (prevents leaking the key to unrelated hosts).
|
||||
internal_url: LAN-facing provider URL. Used to rewrite
|
||||
``external_url`` prefixes so Docker-host downloads stay on the
|
||||
LAN instead of egressing to the public domain.
|
||||
external_url: Public provider URL the notification URL was built
|
||||
from. Only used for the LAN rewrite and the api-key scope check.
|
||||
cache_key: Optional explicit cache key. Providers whose URLs don't
|
||||
embed a stable asset id (Google Photos) pass one through so the
|
||||
file_id cache still works.
|
||||
|
||||
Returns ``None`` iff ``url`` is empty.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
|
||||
if internal_url and external_url and url.startswith(external_url):
|
||||
url = internal_url + url[len(external_url):]
|
||||
|
||||
normalized_type = str(media_type or "").lower()
|
||||
entry_type = "video" if normalized_type == "video" else "photo"
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
provider_urls = [u for u in (internal_url, external_url) if u]
|
||||
if api_key and (not provider_urls or any(url.startswith(u) for u in provider_urls)):
|
||||
headers["x-api-key"] = api_key
|
||||
|
||||
entry: dict[str, Any] = {"url": url, "type": entry_type, "headers": headers}
|
||||
if cache_key:
|
||||
entry["cache_key"] = cache_key
|
||||
return entry
|
||||
|
||||
|
||||
def split_media_by_upload_size(
|
||||
media_items: list[tuple], max_upload_size: int
|
||||
) -> list[list[tuple]]:
|
||||
|
||||
@@ -177,7 +177,9 @@ class ImmichActionExecutor(ActionExecutor):
|
||||
needs_thumbnail = album_id in album_created_now
|
||||
|
||||
if album_id and album_id != "__dry_run_new__":
|
||||
album = await self._client.get_album(album_id)
|
||||
# Actions diff the current album state to decide what to
|
||||
# add — must observe fresh data, not a cached view.
|
||||
album = await self._client.get_album(album_id, use_cache=False)
|
||||
if album is None and create_if_missing and create_album_name:
|
||||
if not dry_run:
|
||||
created = await self._client.create_album(create_album_name)
|
||||
|
||||
@@ -193,6 +193,27 @@ def get_asset_video_url(
|
||||
return None
|
||||
|
||||
|
||||
def build_asset_media_urls(
|
||||
external_url: str, asset_id: str, asset_type: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Return ``(preview_url, full_url)`` for an Immich asset.
|
||||
|
||||
Single source of truth for the photo-vs-video endpoint rule. Used by
|
||||
``asset_to_media`` (notification path) and the bot command handlers
|
||||
(command path) so both always pick the transcoded ``/video/playback``
|
||||
for videos and the preview-sized thumbnail for photos — if they
|
||||
diverge, Telegram ends up delivering a still JPEG for videos in a
|
||||
media group.
|
||||
"""
|
||||
is_video = asset_type == ASSET_TYPE_VIDEO
|
||||
if is_video:
|
||||
preview_url = f"{external_url}/api/assets/{asset_id}/video/playback"
|
||||
else:
|
||||
preview_url = f"{external_url}/api/assets/{asset_id}/thumbnail?size=preview"
|
||||
full_url = f"{external_url}/api/assets/{asset_id}/original"
|
||||
return preview_url, full_url
|
||||
|
||||
|
||||
def build_asset_detail(
|
||||
asset: ImmichAssetInfo,
|
||||
external_url: str,
|
||||
@@ -246,12 +267,7 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
|
||||
# preview_url is what the notification dispatcher feeds to Telegram as the
|
||||
# actual media bytes — for videos it must be the transcoded playback (mp4),
|
||||
# not the JPEG thumbnail, or Telegram receives a JPEG labeled as video/mp4.
|
||||
if asset.type == ASSET_TYPE_VIDEO:
|
||||
preview_url = f"{external_url}/api/assets/{asset.id}/video/playback"
|
||||
full_url = f"{external_url}/api/assets/{asset.id}/original"
|
||||
else:
|
||||
preview_url = f"{external_url}/api/assets/{asset.id}/thumbnail?size=preview"
|
||||
full_url = f"{external_url}/api/assets/{asset.id}/original"
|
||||
preview_url, full_url = build_asset_media_urls(external_url, asset.id, asset.type)
|
||||
|
||||
return MediaAsset(
|
||||
id=asset.id,
|
||||
|
||||
@@ -13,6 +13,18 @@ from .models import ImmichAlbumData, ImmichAssetInfo
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Guard against runaway payloads when a bulk import lands in one poll tick.
|
||||
# Templates iterate every entry in ``added_assets`` / ``removed_asset_ids``
|
||||
# in Jinja for-loops (see defaults/*/assets_added.jinja2), and Telegram's
|
||||
# media group has a hard cap of its own — sending 200k entries would both
|
||||
# crash rendering and produce a message that no transport can deliver.
|
||||
#
|
||||
# ``added_count`` / ``removed_count`` on the event always carry the true
|
||||
# totals so templates can show an accurate "N added" number even when the
|
||||
# per-asset list is truncated.
|
||||
_MAX_ASSETS_PER_EVENT = 50
|
||||
_MAX_REMOVALS_PER_EVENT = 200
|
||||
|
||||
|
||||
def _make_base_extra(new_album: ImmichAlbumData, external_url: str) -> dict:
|
||||
"""Build the common extra dict for album events."""
|
||||
@@ -85,7 +97,17 @@ def detect_album_changes(
|
||||
|
||||
# Emit one event per change type detected
|
||||
if added_assets:
|
||||
media_assets = [asset_to_media(a, external_url) for a in added_assets]
|
||||
total_added = len(added_assets)
|
||||
truncated_added = added_assets[:_MAX_ASSETS_PER_EVENT]
|
||||
media_assets = [asset_to_media(a, external_url) for a in truncated_added]
|
||||
event_extra = dict(extra)
|
||||
if total_added > _MAX_ASSETS_PER_EVENT:
|
||||
event_extra["truncated"] = True
|
||||
event_extra["shown_count"] = _MAX_ASSETS_PER_EVENT
|
||||
_LOGGER.info(
|
||||
"Truncated assets_added event for album %s: %d → %d",
|
||||
new_album.id, total_added, _MAX_ASSETS_PER_EVENT,
|
||||
)
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.ASSETS_ADDED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
@@ -95,12 +117,22 @@ def detect_album_changes(
|
||||
timestamp=now,
|
||||
added_assets=media_assets,
|
||||
removed_asset_ids=[],
|
||||
added_count=len(added_assets),
|
||||
added_count=total_added,
|
||||
removed_count=0,
|
||||
extra=dict(extra),
|
||||
extra=event_extra,
|
||||
))
|
||||
|
||||
if removed_ids:
|
||||
total_removed = len(removed_ids)
|
||||
truncated_removed = list(removed_ids)[:_MAX_REMOVALS_PER_EVENT]
|
||||
event_extra = dict(extra)
|
||||
if total_removed > _MAX_REMOVALS_PER_EVENT:
|
||||
event_extra["truncated"] = True
|
||||
event_extra["shown_count"] = _MAX_REMOVALS_PER_EVENT
|
||||
_LOGGER.info(
|
||||
"Truncated assets_removed event for album %s: %d → %d",
|
||||
new_album.id, total_removed, _MAX_REMOVALS_PER_EVENT,
|
||||
)
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.ASSETS_REMOVED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
@@ -109,10 +141,10 @@ def detect_album_changes(
|
||||
collection_name=new_album.name,
|
||||
timestamp=now,
|
||||
added_assets=[],
|
||||
removed_asset_ids=list(removed_ids),
|
||||
removed_asset_ids=truncated_removed,
|
||||
added_count=0,
|
||||
removed_count=len(removed_ids),
|
||||
extra=dict(extra),
|
||||
removed_count=total_removed,
|
||||
extra=event_extra,
|
||||
))
|
||||
|
||||
if name_changed:
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ...notifications.ssrf import UnsafeURLError, validate_outbound_url
|
||||
from .models import ImmichAlbumData, SharedLinkInfo
|
||||
from .models import ImmichAlbumData, ImmichAlbumMeta, SharedLinkInfo
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,6 +21,51 @@ _LOGGER = logging.getLogger(__name__)
|
||||
MAX_SEARCH_QUERY_LEN = 256
|
||||
MAX_SEARCH_PERSON_IDS = 50
|
||||
|
||||
# Module-level TTL caches for album bodies and shared-link listings. The
|
||||
# Immich ``GET /api/albums/{id}`` response can be tens or hundreds of MB on a
|
||||
# large album, and bot commands like /random, /latest, /memory all refetch
|
||||
# the same album in quick succession. A short TTL makes repeat runs nearly
|
||||
# instant and deduplicates concurrent fetches so a burst of commands issues
|
||||
# one HTTP call instead of N.
|
||||
#
|
||||
# Caches are module-scoped (not instance-scoped) because ``ImmichClient`` is
|
||||
# constructed fresh per request in several places (api/providers.py,
|
||||
# services/action_runner.py, command handlers), so an instance cache would
|
||||
# never survive to serve a second caller. This mirrors ``_users_cache`` in
|
||||
# ``provider.py``.
|
||||
_ALBUM_CACHE_TTL_SECONDS = 60
|
||||
_SHARED_LINKS_CACHE_TTL_SECONDS = 60
|
||||
# Guard rail against runaway memory — a 200k-asset album response can be
|
||||
# ~150 MB, so even modest caps bound the worst case.
|
||||
_ALBUM_CACHE_MAX_ENTRIES = 32
|
||||
_album_cache_lock = asyncio.Lock()
|
||||
# key = (server_digest, album_id); value = (monotonic_ts, raw_api_dict)
|
||||
# Store the raw dict rather than the parsed ``ImmichAlbumData`` so callers
|
||||
# that pass a ``users_cache`` still get owner-name enrichment on cache hits.
|
||||
_album_cache: dict[tuple[str, str], tuple[float, dict[str, Any]]] = {}
|
||||
_shared_links_cache_lock = asyncio.Lock()
|
||||
# key = server_digest; value = (monotonic_ts, {album_id: [SharedLinkInfo, ...]})
|
||||
# The underlying ``/api/shared-links`` endpoint has no per-album filter, so
|
||||
# every call was already paying for the full server-wide list. Caching the
|
||||
# bucketed result once per server turns N per-album calls into one fetch.
|
||||
_shared_links_cache: dict[str, tuple[float, dict[str, list[SharedLinkInfo]]]] = {}
|
||||
|
||||
|
||||
def _server_digest(url: str, api_key: str) -> str:
|
||||
"""Hashed key that avoids putting raw api_key into cache dict keys."""
|
||||
return hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
def invalidate_album_cache() -> None:
|
||||
"""Drop every cached album body. Call after mutations that invalidate
|
||||
the cached view (e.g. integration tests, manual /refresh commands)."""
|
||||
_album_cache.clear()
|
||||
|
||||
|
||||
def invalidate_shared_links_cache() -> None:
|
||||
"""Drop every cached shared-link listing."""
|
||||
_shared_links_cache.clear()
|
||||
|
||||
# User-facing error bodies — Immich responses may leak internal paths,
|
||||
# hostnames, or headers injected by intermediary proxies. These helpers keep
|
||||
# only a short, scrubbed summary; full bodies are logged server-side only.
|
||||
@@ -184,28 +232,100 @@ class ImmichClient:
|
||||
return {}
|
||||
|
||||
async def get_shared_links(self, album_id: str) -> list[SharedLinkInfo]:
|
||||
links: list[SharedLinkInfo] = []
|
||||
bucketed = await self._get_shared_links_bucketed()
|
||||
return list(bucketed.get(album_id, []))
|
||||
|
||||
async def _get_shared_links_bucketed(self) -> dict[str, list[SharedLinkInfo]]:
|
||||
"""Return ``{album_id: [SharedLinkInfo, ...]}`` for the server, hitting
|
||||
the module-level TTL cache first. Underlying Immich endpoint has no
|
||||
per-album filter, so one server-wide fetch serves every caller until
|
||||
the TTL elapses.
|
||||
"""
|
||||
digest = _server_digest(self._url, self._api_key)
|
||||
now = time.monotonic()
|
||||
entry = _shared_links_cache.get(digest)
|
||||
if entry is not None and (now - entry[0]) < _SHARED_LINKS_CACHE_TTL_SECONDS:
|
||||
return entry[1]
|
||||
|
||||
async with _shared_links_cache_lock:
|
||||
# Re-check under the lock — another coroutine may have refreshed
|
||||
# while we waited.
|
||||
entry = _shared_links_cache.get(digest)
|
||||
if entry is not None and (time.monotonic() - entry[0]) < _SHARED_LINKS_CACHE_TTL_SECONDS:
|
||||
return entry[1]
|
||||
fresh = await self.get_all_shared_links_by_album()
|
||||
_shared_links_cache[digest] = (time.monotonic(), fresh)
|
||||
return fresh
|
||||
|
||||
async def get_all_shared_links_by_album(self) -> dict[str, list[SharedLinkInfo]]:
|
||||
"""Fetch every shared link on the server, bucketed by album id.
|
||||
|
||||
Immich's ``/api/shared-links`` endpoint is server-wide — there's no
|
||||
per-album filter server-side — so every call that wanted the links
|
||||
for a single album was already paying the cost of the full listing
|
||||
and then discarding most of the response. Callers that need links
|
||||
for multiple albums in one tick should use this method and index
|
||||
into the returned dict instead of hitting ``get_shared_links`` in
|
||||
a loop.
|
||||
|
||||
Returns an empty dict on any error (matches the silent-failure
|
||||
contract of ``get_shared_links`` so callers don't need to branch
|
||||
on transient outages).
|
||||
"""
|
||||
result: dict[str, list[SharedLinkInfo]] = {}
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/shared-links",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
for link in data:
|
||||
album = link.get("album")
|
||||
key = link.get("key")
|
||||
if album and key and album.get("id") == album_id:
|
||||
links.append(SharedLinkInfo.from_api_response(link))
|
||||
if response.status != 200:
|
||||
_LOGGER.warning(
|
||||
"get_all_shared_links non-200: HTTP %s", response.status
|
||||
)
|
||||
return result
|
||||
data = await response.json()
|
||||
for link in data:
|
||||
album = link.get("album")
|
||||
key = link.get("key")
|
||||
if not (album and key):
|
||||
continue
|
||||
aid = album.get("id")
|
||||
if not aid:
|
||||
continue
|
||||
result.setdefault(aid, []).append(
|
||||
SharedLinkInfo.from_api_response(link)
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to fetch shared links: %s", err)
|
||||
return links
|
||||
_LOGGER.warning("Failed to fetch all shared links: %s", err)
|
||||
return result
|
||||
|
||||
async def get_album(
|
||||
self,
|
||||
album_id: str,
|
||||
users_cache: dict[str, str] | None = None,
|
||||
*,
|
||||
use_cache: bool = True,
|
||||
) -> ImmichAlbumData | None:
|
||||
"""Fetch an album by id, optionally serving from the module-level
|
||||
TTL cache. Pass ``use_cache=False`` from paths that must observe the
|
||||
current server state (e.g. the notification poll loop's full-fetch
|
||||
path, where a stale cached entry would delay asset-removal events).
|
||||
Non-cached fetches still populate the cache for subsequent readers.
|
||||
"""
|
||||
cache_key = (_server_digest(self._url, self._api_key), album_id)
|
||||
if use_cache:
|
||||
entry = _album_cache.get(cache_key)
|
||||
if entry is not None and (time.monotonic() - entry[0]) < _ALBUM_CACHE_TTL_SECONDS:
|
||||
# Rehydrate per-call so ``users_cache`` enrichment is applied
|
||||
# with the caller's dict, not whichever one was live when the
|
||||
# cache was populated.
|
||||
return ImmichAlbumData.from_api_response(entry[1], users_cache)
|
||||
|
||||
# Deliberately fetch without holding a lock so concurrent calls for
|
||||
# *different* album_ids (the common case from asyncio.gather in
|
||||
# fetch_albums_with_links) stay parallel. The worst case is a small
|
||||
# duplicate-fetch stampede when two requests miss the same album at
|
||||
# the same instant — acceptable for our scale.
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/albums/{album_id}",
|
||||
@@ -218,10 +338,132 @@ class ImmichClient:
|
||||
f"Error fetching album {album_id}: HTTP {response.status}"
|
||||
)
|
||||
data = await response.json()
|
||||
return ImmichAlbumData.from_api_response(data, users_cache)
|
||||
except aiohttp.ClientError as err:
|
||||
raise ImmichApiError(f"Error communicating with Immich: {err}") from err
|
||||
|
||||
async with _album_cache_lock:
|
||||
# Evict the oldest entry if we're at the cap — simple FIFO is fine
|
||||
# for our access pattern (commands touch a small working set).
|
||||
if len(_album_cache) >= _ALBUM_CACHE_MAX_ENTRIES and cache_key not in _album_cache:
|
||||
oldest = min(_album_cache.items(), key=lambda kv: kv[1][0])[0]
|
||||
_album_cache.pop(oldest, None)
|
||||
_album_cache[cache_key] = (time.monotonic(), data)
|
||||
return ImmichAlbumData.from_api_response(data, users_cache)
|
||||
|
||||
async def get_album_meta(self, album_id: str) -> ImmichAlbumMeta | None:
|
||||
"""Fetch album metadata without the assets array.
|
||||
|
||||
Uses Immich's ``?withoutAssets=true`` query param, which skips the
|
||||
(potentially huge) ``assets`` field. A 200k-asset album response
|
||||
drops from ~150 MB to a few hundred bytes, so this is cheap enough
|
||||
to run on every poll as a change-detection probe.
|
||||
"""
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/albums/{album_id}",
|
||||
params={"withoutAssets": "true"},
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 404:
|
||||
return None
|
||||
if response.status != 200:
|
||||
raise ImmichApiError(
|
||||
f"Error fetching album meta {album_id}: HTTP {response.status}"
|
||||
)
|
||||
data = await response.json()
|
||||
return ImmichAlbumMeta.from_api_response(data)
|
||||
except aiohttp.ClientError as err:
|
||||
raise ImmichApiError(f"Error communicating with Immich: {err}") from err
|
||||
|
||||
async def search_album_assets_updated_after(
|
||||
self,
|
||||
album_id: str,
|
||||
updated_after: str,
|
||||
*,
|
||||
page_size: int = 1000,
|
||||
max_pages: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch assets in ``album_id`` whose ``updatedAt`` is after ``updated_after``.
|
||||
|
||||
Uses ``POST /api/search/metadata`` with ``albumIds=[album_id]`` and
|
||||
``updatedAfter=<iso>``. Paginates through up to ``max_pages`` pages —
|
||||
the cap exists so a clock-skew or upstream bug cannot produce an
|
||||
infinite loop that exhausts memory on a 200k-asset album. In practice
|
||||
an active album sees a few hundred updated assets per tick and
|
||||
terminates after one page.
|
||||
|
||||
Returns raw Immich asset dicts (same shape as ``album.assets[*]``
|
||||
from ``get_album``), so callers can feed them into
|
||||
``ImmichAssetInfo.from_api_response`` directly.
|
||||
"""
|
||||
if not updated_after:
|
||||
return []
|
||||
|
||||
page_size = max(1, min(page_size, 1000))
|
||||
results: list[dict[str, Any]] = []
|
||||
for page in range(1, max_pages + 1):
|
||||
payload: dict[str, Any] = {
|
||||
"albumIds": [album_id],
|
||||
"updatedAfter": updated_after,
|
||||
"page": page,
|
||||
"size": page_size,
|
||||
# ``withExif`` keeps location/description parity with
|
||||
# ``get_album`` so downstream ``ImmichAssetInfo.from_api_response``
|
||||
# populates city/country/rating on the delta path too.
|
||||
"withExif": True,
|
||||
"withPeople": True,
|
||||
}
|
||||
try:
|
||||
async with self._session.post(
|
||||
f"{self._url}/api/search/metadata",
|
||||
headers=self._json_headers,
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
body_snip = await response.text()
|
||||
_LOGGER.warning(
|
||||
"Immich delta search non-200: HTTP %s body=%s",
|
||||
response.status, _redact_body(body_snip),
|
||||
)
|
||||
break
|
||||
data = await response.json()
|
||||
assets_block = data.get("assets")
|
||||
if isinstance(assets_block, dict):
|
||||
items = assets_block.get("items", []) or []
|
||||
next_page = assets_block.get("nextPage")
|
||||
elif isinstance(assets_block, list):
|
||||
items = assets_block
|
||||
next_page = None
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Immich delta search returned unexpected shape: keys=%s",
|
||||
list(data.keys())[:5],
|
||||
)
|
||||
break
|
||||
|
||||
results.extend(items)
|
||||
|
||||
# Stop early on the last page. Immich returns nextPage as
|
||||
# the next page number (string or int) or None/empty when
|
||||
# exhausted. Fall back to page-fullness heuristic if the
|
||||
# server omits the pagination hint.
|
||||
if next_page is None or next_page == "" or next_page == 0:
|
||||
break
|
||||
if len(items) < page_size:
|
||||
break
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Immich delta search transport error: %s", err)
|
||||
break
|
||||
except Exception as err: # noqa: BLE001 — resilience over correctness
|
||||
_LOGGER.warning("Immich delta search parse error: %s", err)
|
||||
break
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Immich delta search for album %s hit max_pages=%d cap",
|
||||
album_id, max_pages,
|
||||
)
|
||||
return results
|
||||
|
||||
async def get_albums(self) -> list[dict[str, Any]]:
|
||||
try:
|
||||
async with self._session.get(
|
||||
|
||||
@@ -146,6 +146,49 @@ class ImmichAssetInfo:
|
||||
return bool(thumbhash)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImmichAlbumMeta:
|
||||
"""Lightweight album metadata from ``GET /api/albums/{id}?withoutAssets=true``.
|
||||
|
||||
Used as a cheap change-detection probe so we can skip the multi-MB
|
||||
full-asset fetch when nothing interesting has changed. Large albums
|
||||
(tens to hundreds of thousands of assets) would otherwise re-serialize
|
||||
the entire asset list on every poll interval.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
asset_count: int
|
||||
updated_at: str
|
||||
shared: bool
|
||||
thumbnail_asset_id: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, data: dict[str, Any]) -> ImmichAlbumMeta:
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data.get("albumName", "Unnamed"),
|
||||
asset_count=int(data.get("assetCount", 0) or 0),
|
||||
updated_at=data.get("updatedAt", "") or "",
|
||||
shared=bool(data.get("shared", False)),
|
||||
thumbnail_asset_id=data.get("albumThumbnailAssetId"),
|
||||
)
|
||||
|
||||
def fingerprint(self) -> dict[str, Any]:
|
||||
"""Return a minimal serializable dict for persistence + equality checks.
|
||||
|
||||
We purposefully exclude ``id`` (known from the state row) and keep the
|
||||
dict flat so JSON round-trips are cheap and stable for equality.
|
||||
"""
|
||||
return {
|
||||
"updated_at": self.updated_at,
|
||||
"asset_count": self.asset_count,
|
||||
"shared": self.shared,
|
||||
"name": self.name,
|
||||
"thumbnail_asset_id": self.thumbnail_asset_id or "",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImmichAlbumData:
|
||||
"""Full album data from Immich API."""
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -11,13 +14,62 @@ from notify_bridge_core.models.events import ServiceEvent
|
||||
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
|
||||
from notify_bridge_core.templates.variables import TemplateVariableDefinition
|
||||
|
||||
from .change_detector import detect_album_changes
|
||||
from .asset_utils import asset_to_media
|
||||
from .change_detector import _MAX_ASSETS_PER_EVENT, detect_album_changes
|
||||
from .client import ImmichClient
|
||||
from .models import ImmichAlbumData
|
||||
from .models import ImmichAlbumData, ImmichAlbumMeta, ImmichAssetInfo
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Module-level users cache shared across ImmichServiceProvider instances.
|
||||
# Users change rarely (new people joining the server, display-name edits), so
|
||||
# refetching on every tracker's ``connect()`` is wasteful — a fleet of 10
|
||||
# trackers on the same Immich server otherwise issues 10 ``GET /api/users``
|
||||
# calls per poll cycle. TTL is conservative (1h) and a hashed key keeps the
|
||||
# raw api_key out of dict keys in case of a memory dump.
|
||||
_USERS_CACHE_TTL_SECONDS = 3600
|
||||
_users_cache_lock = asyncio.Lock()
|
||||
_users_cache: dict[str, tuple[float, dict[str, str]]] = {}
|
||||
|
||||
|
||||
def _users_cache_key(url: str, api_key: str) -> str:
|
||||
digest = hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()
|
||||
return digest[:32]
|
||||
|
||||
|
||||
async def _get_cached_users(
|
||||
client: ImmichClient, url: str, api_key: str
|
||||
) -> dict[str, str]:
|
||||
"""Return ``{user_id: display_name}`` for the server, reusing cache entries
|
||||
whose TTL has not elapsed. Misses and stale hits fall through to a real
|
||||
fetch under a single lock so concurrent polls don't stampede the server.
|
||||
"""
|
||||
key = _users_cache_key(url, api_key)
|
||||
now = time.monotonic()
|
||||
entry = _users_cache.get(key)
|
||||
if entry is not None and (now - entry[0]) < _USERS_CACHE_TTL_SECONDS:
|
||||
return entry[1]
|
||||
|
||||
async with _users_cache_lock:
|
||||
# Re-check after acquiring the lock — another coroutine may have
|
||||
# refreshed the entry while we waited.
|
||||
entry = _users_cache.get(key)
|
||||
if entry is not None and (time.monotonic() - entry[0]) < _USERS_CACHE_TTL_SECONDS:
|
||||
return entry[1]
|
||||
fresh = await client.get_users()
|
||||
_users_cache[key] = (time.monotonic(), fresh)
|
||||
return fresh
|
||||
|
||||
|
||||
def invalidate_users_cache() -> None:
|
||||
"""Drop every cached users dict. Exposed for callers that mutate users
|
||||
(e.g. provider config changes, integration tests) and need the next
|
||||
``connect()`` to re-fetch.
|
||||
"""
|
||||
_users_cache.clear()
|
||||
|
||||
|
||||
# Immich-specific template variables
|
||||
IMMICH_VARIABLES: list[TemplateVariableDefinition] = [
|
||||
TemplateVariableDefinition(
|
||||
@@ -135,7 +187,9 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
await self._client.get_server_config()
|
||||
if self._external_domain:
|
||||
self._client.external_domain = self._external_domain
|
||||
self._users_cache = await self._client.get_users()
|
||||
self._users_cache = await _get_cached_users(
|
||||
self._client, self._client.url, self._client.api_key,
|
||||
)
|
||||
return ok
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
@@ -150,9 +204,32 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
new_state = dict(tracker_state)
|
||||
external_url = self._client.external_url
|
||||
|
||||
for album_id in collection_ids:
|
||||
album = await self._client.get_album(album_id, self._users_cache)
|
||||
if album is None:
|
||||
# Tick-scoped share-link cache. Populated lazily on first enrichment;
|
||||
# a tracker watching 5 albums with changes now issues 1 ``/api/shared-links``
|
||||
# request per tick instead of 5 (and the endpoint is server-wide — each
|
||||
# call was already fetching all links and discarding most of them).
|
||||
self._tick_shared_links: dict[str, list] | None = None
|
||||
|
||||
# Fan out the cheap meta probes in parallel. For a tracker that
|
||||
# watches 20 albums on the same Immich server this turns a 20-hop
|
||||
# serial wait into ~1 round-trip's worth of latency. aiohttp's
|
||||
# connection pool caps concurrency per host, so this can't stampede.
|
||||
meta_results = await asyncio.gather(
|
||||
*(self._client.get_album_meta(aid) for aid in collection_ids),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
for album_id, meta_or_exc in zip(collection_ids, meta_results):
|
||||
if isinstance(meta_or_exc, BaseException):
|
||||
# Transient failure on this album — preserve existing state
|
||||
# and move on. Logging at warning so flaky albums surface in
|
||||
# the log without flooding on hard outages.
|
||||
_LOGGER.warning(
|
||||
"Meta probe failed for album %s: %s", album_id, meta_or_exc,
|
||||
)
|
||||
continue
|
||||
meta = meta_or_exc
|
||||
if meta is None:
|
||||
# Album deleted
|
||||
if album_id in new_state:
|
||||
from notify_bridge_core.models.events import EventType
|
||||
@@ -168,11 +245,80 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
del new_state[album_id]
|
||||
continue
|
||||
|
||||
# Get previous state
|
||||
prev = new_state.get(album_id)
|
||||
prev_fingerprint = prev.get("meta_fingerprint") if prev else None
|
||||
has_pending = bool(prev and prev.get("pending_asset_ids"))
|
||||
|
||||
# 2) Fast-path: fingerprint match and no pending assets → no work.
|
||||
# We still refresh the fingerprint slot (no-op if identical) and
|
||||
# leave asset_ids untouched on disk.
|
||||
if (
|
||||
prev is not None
|
||||
and prev_fingerprint == meta.fingerprint()
|
||||
and not has_pending
|
||||
):
|
||||
continue
|
||||
|
||||
# 3) Decide: delta fetch (cheap, active-album case) or full
|
||||
# fetch (first tick + reconciliation for removals).
|
||||
old_fp = prev.get("meta_fingerprint") if prev else None
|
||||
old_asset_count = (old_fp or {}).get("asset_count", 0)
|
||||
old_updated_at = (old_fp or {}).get("updated_at", "")
|
||||
|
||||
# Gate for the delta path:
|
||||
# - must be tracked already (prev exists, has asset_ids)
|
||||
# - must have a prior timestamp (empty ⇒ migrated DB row)
|
||||
# - asset_count must not have decreased (removals need full fetch)
|
||||
can_delta = (
|
||||
prev is not None
|
||||
and bool(prev.get("asset_ids"))
|
||||
and bool(old_updated_at)
|
||||
and meta.asset_count >= old_asset_count
|
||||
)
|
||||
|
||||
if can_delta:
|
||||
delta_events = await self._poll_delta(
|
||||
album_id=album_id,
|
||||
prev=prev,
|
||||
new_meta=meta,
|
||||
old_updated_at=old_updated_at,
|
||||
)
|
||||
if delta_events is not None:
|
||||
events.extend(delta_events["events"])
|
||||
new_state[album_id] = delta_events["new_state"]
|
||||
continue
|
||||
# delta_events is None ⇒ delta saw more additions than the
|
||||
# net count increase (mixed add+remove) ⇒ fall through to
|
||||
# the full-fetch path so removals get detected.
|
||||
|
||||
# Full fetch: first tick, or count-decreased, or delta-unsafe.
|
||||
# Bypass the module-level album cache — this path runs when we
|
||||
# specifically need the current server state (e.g. to detect
|
||||
# asset removals), so a stale cached entry would silently delay
|
||||
# the event.
|
||||
album = await self._client.get_album(
|
||||
album_id, self._users_cache, use_cache=False,
|
||||
)
|
||||
if album is None:
|
||||
# Album was deleted between meta probe and full fetch — handle
|
||||
# the deletion the same way as above.
|
||||
if album_id in new_state:
|
||||
from notify_bridge_core.models.events import EventType
|
||||
from datetime import datetime, timezone
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.COLLECTION_DELETED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=self._name,
|
||||
collection_id=album_id,
|
||||
collection_name=new_state.get(album_id, {}).get("name", "Unknown"),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
))
|
||||
del new_state[album_id]
|
||||
continue
|
||||
|
||||
if prev is None:
|
||||
# First time seeing this album — store state, no event
|
||||
new_state[album_id] = _serialize_album_state(album)
|
||||
new_state[album_id] = _serialize_album_state(album, meta)
|
||||
continue
|
||||
|
||||
# Reconstruct previous album data for comparison
|
||||
@@ -184,34 +330,233 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
)
|
||||
|
||||
if detected_events:
|
||||
# Fetch shared links to enrich events 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
|
||||
for evt in detected_events:
|
||||
if public_link:
|
||||
evt.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
|
||||
elif protected_link:
|
||||
evt.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
|
||||
|
||||
await self._enrich_with_shared_links(album_id, detected_events)
|
||||
events.extend(detected_events)
|
||||
|
||||
# Update state
|
||||
state = _serialize_album_state(album)
|
||||
state = _serialize_album_state(album, meta)
|
||||
state["pending_asset_ids"] = list(updated_pending)
|
||||
new_state[album_id] = state
|
||||
|
||||
return events, new_state
|
||||
|
||||
async def _poll_delta(
|
||||
self,
|
||||
*,
|
||||
album_id: str,
|
||||
prev: dict[str, Any],
|
||||
new_meta: ImmichAlbumMeta,
|
||||
old_updated_at: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Delta-fetch path for an active album.
|
||||
|
||||
Calls ``search/metadata`` with ``updatedAfter`` instead of pulling
|
||||
the full asset list. Returns a dict with ``events`` and ``new_state``
|
||||
on success, or ``None`` to signal the caller to retry via full fetch
|
||||
(used when a mixed add+remove is detected — the delta endpoint can't
|
||||
tell us *what* was removed, only that additions alone don't account
|
||||
for the net count change).
|
||||
|
||||
Trades strict detection of removals-during-mixed-changes for a
|
||||
drastic reduction in bytes fetched per tick. On a 200k-asset album
|
||||
where 50 were just added, we fetch ~50 asset records instead of
|
||||
200 000.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from notify_bridge_core.models.events import EventType
|
||||
|
||||
prev_asset_ids: set[str] = set(prev.get("asset_ids", []))
|
||||
prev_pending: set[str] = set(prev.get("pending_asset_ids", []))
|
||||
|
||||
raw_assets = await self._client.search_album_assets_updated_after(
|
||||
album_id, old_updated_at
|
||||
)
|
||||
|
||||
# Parse everything that came back. We need unprocessed entries too
|
||||
# (they feed the ``pending_asset_ids`` list used by the original
|
||||
# change detector's processed-later logic).
|
||||
delta_assets: list[ImmichAssetInfo] = []
|
||||
for raw in raw_assets:
|
||||
try:
|
||||
delta_assets.append(
|
||||
ImmichAssetInfo.from_api_response(raw, self._users_cache)
|
||||
)
|
||||
except Exception as err: # noqa: BLE001 — one bad record ≠ abort tick
|
||||
_LOGGER.warning(
|
||||
"Skipping malformed asset record in delta response: %s", err
|
||||
)
|
||||
|
||||
newly_added: list[ImmichAssetInfo] = []
|
||||
still_pending: set[str] = set()
|
||||
for asset in delta_assets:
|
||||
if asset.is_processed:
|
||||
if asset.id not in prev_asset_ids:
|
||||
newly_added.append(asset)
|
||||
else:
|
||||
still_pending.add(asset.id)
|
||||
|
||||
old_asset_count = int((prev.get("meta_fingerprint") or {}).get("asset_count", 0))
|
||||
net_change = new_meta.asset_count - old_asset_count
|
||||
|
||||
# If delta found more "added" assets than the net count change,
|
||||
# a concurrent removal happened. Full fetch is the only way to
|
||||
# know what was removed — bail out so the caller retries.
|
||||
if net_change >= 0 and len(newly_added) > net_change:
|
||||
_LOGGER.info(
|
||||
"Delta for album %s found %d additions but net change is %d "
|
||||
"— falling back to full fetch for removal reconciliation",
|
||||
album_id, len(newly_added), net_change,
|
||||
)
|
||||
return None
|
||||
|
||||
# Mirror case: positive net change we couldn't account for with the
|
||||
# delta results (possibly clock skew on ``updated_at``, or an asset
|
||||
# whose timestamp is before ``old_updated_at`` yet the album's
|
||||
# ``updatedAt`` bumped). Full fetch to avoid silently missing adds.
|
||||
if net_change > 0 and len(newly_added) < net_change:
|
||||
_LOGGER.info(
|
||||
"Delta for album %s found %d additions but net change is %d "
|
||||
"— falling back to full fetch to avoid missing assets",
|
||||
album_id, len(newly_added), net_change,
|
||||
)
|
||||
return None
|
||||
|
||||
events: list[ServiceEvent] = []
|
||||
now = datetime.now(timezone.utc)
|
||||
external_url = self._external_domain or self._client.external_url
|
||||
album_url = f"{external_url}/albums/{album_id}"
|
||||
|
||||
# Carry album-level attributes we know from the cheap meta probe.
|
||||
# Shared-link enrichment happens further down only if we emitted
|
||||
# any asset events.
|
||||
base_extra = {
|
||||
"album_url": album_url,
|
||||
"shared": new_meta.shared,
|
||||
"asset_count": new_meta.asset_count,
|
||||
"photo_count": 0, # unknown without per-asset scan; templates tolerate 0
|
||||
"video_count": 0,
|
||||
"people": [],
|
||||
"owner": "",
|
||||
}
|
||||
|
||||
# Metadata-only events (no asset fetch needed)
|
||||
old_fp = prev.get("meta_fingerprint") or {}
|
||||
if old_fp.get("name") and old_fp["name"] != new_meta.name:
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.COLLECTION_RENAMED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=self._name,
|
||||
collection_id=album_id,
|
||||
collection_name=new_meta.name,
|
||||
timestamp=now,
|
||||
added_assets=[],
|
||||
removed_asset_ids=[],
|
||||
added_count=0,
|
||||
removed_count=0,
|
||||
old_name=old_fp["name"],
|
||||
new_name=new_meta.name,
|
||||
extra=dict(base_extra),
|
||||
))
|
||||
|
||||
if "shared" in old_fp and bool(old_fp["shared"]) != bool(new_meta.shared):
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.SHARING_CHANGED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=self._name,
|
||||
collection_id=album_id,
|
||||
collection_name=new_meta.name,
|
||||
timestamp=now,
|
||||
added_assets=[],
|
||||
removed_asset_ids=[],
|
||||
added_count=0,
|
||||
removed_count=0,
|
||||
old_shared=bool(old_fp["shared"]),
|
||||
new_shared=bool(new_meta.shared),
|
||||
extra=dict(base_extra),
|
||||
))
|
||||
|
||||
if newly_added:
|
||||
total_added = len(newly_added)
|
||||
truncated = newly_added[:_MAX_ASSETS_PER_EVENT]
|
||||
media_assets = [
|
||||
asset_to_media(a, self._client.external_url) for a in truncated
|
||||
]
|
||||
extra = dict(base_extra)
|
||||
if total_added > _MAX_ASSETS_PER_EVENT:
|
||||
extra["truncated"] = True
|
||||
extra["shown_count"] = _MAX_ASSETS_PER_EVENT
|
||||
_LOGGER.info(
|
||||
"Delta-path truncated assets_added event for album %s: %d → %d",
|
||||
album_id, total_added, _MAX_ASSETS_PER_EVENT,
|
||||
)
|
||||
events.append(ServiceEvent(
|
||||
event_type=EventType.ASSETS_ADDED,
|
||||
provider_type=ServiceProviderType.IMMICH,
|
||||
provider_name=self._name,
|
||||
collection_id=album_id,
|
||||
collection_name=new_meta.name,
|
||||
timestamp=now,
|
||||
added_assets=media_assets,
|
||||
removed_asset_ids=[],
|
||||
added_count=total_added,
|
||||
removed_count=0,
|
||||
extra=extra,
|
||||
))
|
||||
|
||||
if events:
|
||||
await self._enrich_with_shared_links(album_id, events)
|
||||
|
||||
# Rebuild state. asset_ids grows by the newly-added processed set.
|
||||
# pending is the union of the prior pending list (things still in
|
||||
# flight) and anything the delta confirmed as not-yet-processed.
|
||||
# When net_change is 0 or negative we trust the meta count over
|
||||
# our bookkeeping — skip-path will fix drift on the next full fetch.
|
||||
new_asset_ids = prev_asset_ids | {a.id for a in newly_added}
|
||||
# Discard any previously-pending IDs that just landed as processed.
|
||||
new_pending = (prev_pending | still_pending) - {a.id for a in newly_added}
|
||||
|
||||
return {
|
||||
"events": events,
|
||||
"new_state": {
|
||||
"name": new_meta.name,
|
||||
"asset_ids": list(new_asset_ids),
|
||||
"shared": new_meta.shared,
|
||||
"pending_asset_ids": list(new_pending),
|
||||
"meta_fingerprint": new_meta.fingerprint(),
|
||||
},
|
||||
}
|
||||
|
||||
async def _enrich_with_shared_links(
|
||||
self, album_id: str, events_to_enrich: list[ServiceEvent]
|
||||
) -> None:
|
||||
"""Attach public/protected share link URLs to events for this album.
|
||||
|
||||
Uses the tick-scoped bulk cache populated lazily on first call, so a
|
||||
tracker with changes across N albums makes one ``/api/shared-links``
|
||||
request per tick instead of N.
|
||||
"""
|
||||
if self._tick_shared_links is None:
|
||||
self._tick_shared_links = await self._client.get_all_shared_links_by_album()
|
||||
|
||||
shared_links = self._tick_shared_links.get(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
|
||||
for evt in events_to_enrich:
|
||||
if public_link:
|
||||
evt.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
|
||||
elif protected_link:
|
||||
evt.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
|
||||
|
||||
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
||||
return list(IMMICH_VARIABLES)
|
||||
|
||||
@@ -262,13 +607,33 @@ class ImmichServiceProvider(ServiceProvider):
|
||||
return {"ok": False, "message": "Failed to connect to Immich"}
|
||||
|
||||
|
||||
def _serialize_album_state(album: ImmichAlbumData) -> dict[str, Any]:
|
||||
"""Serialize album state for persistence."""
|
||||
def _serialize_album_state(
|
||||
album: ImmichAlbumData,
|
||||
meta: ImmichAlbumMeta | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Serialize album state for persistence.
|
||||
|
||||
``meta`` carries the fingerprint used for cheap no-change detection on
|
||||
subsequent polls. When omitted (legacy callers, tests) we synthesize a
|
||||
best-effort fingerprint from the full album — it will still match on the
|
||||
next tick if nothing changed, which is what matters.
|
||||
"""
|
||||
if meta is None:
|
||||
fingerprint = {
|
||||
"updated_at": album.updated_at,
|
||||
"asset_count": len(album.asset_ids),
|
||||
"shared": album.shared,
|
||||
"name": album.name,
|
||||
"thumbnail_asset_id": album.thumbnail_asset_id or "",
|
||||
}
|
||||
else:
|
||||
fingerprint = meta.fingerprint()
|
||||
return {
|
||||
"name": album.name,
|
||||
"asset_ids": list(album.asset_ids),
|
||||
"shared": album.shared,
|
||||
"pending_asset_ids": [],
|
||||
"meta_fingerprint": fingerprint,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-server"
|
||||
version = "0.2.3"
|
||||
version = "0.3.1"
|
||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -20,7 +20,8 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
_SETTING_KEYS = {
|
||||
"external_url": "NOTIFY_BRIDGE_EXTERNAL_URL",
|
||||
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
|
||||
"telegram_cache_ttl_hours": None, # no env fallback, default 48
|
||||
"telegram_cache_ttl_hours": None, # URL cache TTL; 0 disables TTL
|
||||
"telegram_asset_cache_max_entries": None, # LRU cap for both caches
|
||||
"supported_locales": None, # comma-separated locale codes
|
||||
"timezone": "NOTIFY_BRIDGE_TIMEZONE", # IANA tz (e.g. "Europe/Warsaw"); empty = UTC
|
||||
}
|
||||
@@ -28,11 +29,18 @@ _SETTING_KEYS = {
|
||||
_DEFAULTS = {
|
||||
"external_url": "",
|
||||
"telegram_webhook_secret": "",
|
||||
"telegram_cache_ttl_hours": "48",
|
||||
# 720h = 30d. URL cache only; asset cache uses thumbhash validation
|
||||
# (content-addressable) and ignores TTL entirely.
|
||||
"telegram_cache_ttl_hours": "720",
|
||||
"telegram_asset_cache_max_entries": "5000",
|
||||
"supported_locales": "en,ru",
|
||||
"timezone": "UTC",
|
||||
}
|
||||
|
||||
# Settings whose changes require dropping in-memory Telegram caches so the
|
||||
# next dispatch rebuilds them with the new parameters. Files are preserved.
|
||||
_CACHE_SETTING_KEYS = {"telegram_cache_ttl_hours", "telegram_asset_cache_max_entries"}
|
||||
|
||||
|
||||
async def get_setting(session: AsyncSession, key: str) -> str:
|
||||
"""Read a setting from DB, falling back to env var then default."""
|
||||
@@ -48,9 +56,14 @@ async def get_setting(session: AsyncSession, key: str) -> str:
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
# Numeric fields declared as int|str so clients can send either form.
|
||||
# Svelte's bind:value on <input type="number"> coerces to a JS number,
|
||||
# so the frontend sends ints for these; older/manual clients may send
|
||||
# strings. We normalize to str before persisting.
|
||||
external_url: str | None = None
|
||||
telegram_webhook_secret: str | None = None
|
||||
telegram_cache_ttl_hours: str | None = None
|
||||
telegram_cache_ttl_hours: int | str | None = None
|
||||
telegram_asset_cache_max_entries: int | str | None = None
|
||||
supported_locales: str | None = None
|
||||
timezone: str | None = None
|
||||
|
||||
@@ -80,19 +93,39 @@ async def update_settings(
|
||||
"""Update app settings (admin). Re-registers webhooks when base URL changes."""
|
||||
old_base_url = await get_setting(session, "external_url")
|
||||
old_secret = await get_setting(session, "telegram_webhook_secret")
|
||||
old_cache_values = {k: await get_setting(session, k) for k in _CACHE_SETTING_KEYS}
|
||||
|
||||
for key in _SETTING_KEYS:
|
||||
value = getattr(body, key, None)
|
||||
if value is None:
|
||||
continue
|
||||
value_str = str(value)
|
||||
# GET masks the webhook secret as "***<last4>" so the real value is
|
||||
# never exposed to the frontend. If the client sends the mask back
|
||||
# (which happens on every save, since bind:value holds whatever GET
|
||||
# returned), treat it as "unchanged" — otherwise we'd overwrite the
|
||||
# real secret with its mask, silently breaking webhook HMAC.
|
||||
if key == "telegram_webhook_secret" and value_str.startswith("***"):
|
||||
continue
|
||||
row = await session.get(AppSetting, key)
|
||||
if row:
|
||||
row.value = value
|
||||
row.value = value_str
|
||||
else:
|
||||
row = AppSetting(key=key, value=value)
|
||||
row = AppSetting(key=key, value=value_str)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
|
||||
# Drop in-memory caches if any cache-tuning setting actually changed, so
|
||||
# the next dispatch rebuilds them with the new parameters. Files survive.
|
||||
cache_changed = False
|
||||
for key in _CACHE_SETTING_KEYS:
|
||||
if await get_setting(session, key) != old_cache_values[key]:
|
||||
cache_changed = True
|
||||
break
|
||||
if cache_changed:
|
||||
from ..services.watcher import reset_telegram_caches_in_memory
|
||||
await reset_telegram_caches_in_memory()
|
||||
|
||||
new_base_url = await get_setting(session, "external_url")
|
||||
new_secret = await get_setting(session, "telegram_webhook_secret")
|
||||
|
||||
@@ -111,6 +144,25 @@ async def update_settings(
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/telegram-cache/stats")
|
||||
async def telegram_cache_stats(
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
"""Return counts and sizes for the Telegram file_id caches."""
|
||||
from ..services.watcher import get_telegram_cache_stats
|
||||
return await get_telegram_cache_stats()
|
||||
|
||||
|
||||
@router.post("/telegram-cache/clear")
|
||||
async def clear_telegram_cache(
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
"""Clear the Telegram file_id cache (URL and asset) from disk and memory."""
|
||||
from ..services.watcher import clear_telegram_caches
|
||||
result = await clear_telegram_caches()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/locales")
|
||||
async def get_supported_locales(
|
||||
user: User = Depends(get_current_user),
|
||||
|
||||
@@ -41,6 +41,36 @@ _rate_limits: TTLCache = TTLCache(maxsize=10000, ttl=3600)
|
||||
# Maximum responses per command to avoid Telegram rate limits
|
||||
_MAX_RESPONSES_PER_COMMAND = 5
|
||||
|
||||
# Commands that fetch assets from the service provider and usually reply
|
||||
# with media — "uploading photo" is the accurate UX hint while we wait on
|
||||
# the provider API + Telegram upload.
|
||||
_UPLOAD_PHOTO_COMMANDS = frozenset({
|
||||
"latest", "random", "favorites", "memory",
|
||||
"search", "find", "person", "place",
|
||||
})
|
||||
|
||||
# Commands that fetch from the provider but reply with text only.
|
||||
# "typing" is accurate; we still want an indicator because the fetch is slow.
|
||||
_TYPING_COMMANDS = frozenset({"summary"})
|
||||
|
||||
|
||||
def classify_command_chat_action(text: str) -> str | None:
|
||||
"""Return the Telegram chat-action hint to show for this command, or None.
|
||||
|
||||
The classification is by command name alone — good enough for the
|
||||
cases where a chat action is worthwhile (slow provider fetches). Fast
|
||||
DB-only commands (``/status``, ``/albums``, ``/events``, ``/people``)
|
||||
return ``None`` and skip the indicator entirely.
|
||||
"""
|
||||
cmd, _, _ = parse_command(text)
|
||||
if not cmd:
|
||||
return None
|
||||
if cmd in _UPLOAD_PHOTO_COMMANDS:
|
||||
return "upload_photo"
|
||||
if cmd in _TYPING_COMMANDS:
|
||||
return "typing"
|
||||
return None
|
||||
|
||||
|
||||
def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int]) -> int | None:
|
||||
"""Check rate limit. Returns seconds to wait, or None if OK."""
|
||||
@@ -367,20 +397,23 @@ async def send_reply(
|
||||
bot_token: str, chat_id: str, text: str, reply_to_message_id: int | None = None,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
) -> None:
|
||||
"""Send a text reply via TelegramClient.
|
||||
"""Send a text reply to a chat.
|
||||
|
||||
Command responses are listings (albums, people, events, ...) that embed
|
||||
multiple links; Telegram's default behavior of rendering a preview of
|
||||
the first URL is almost never what the user wants and clashes with the
|
||||
"Disable link previews" toggle operators set on their Telegram target.
|
||||
We always pass ``disable_web_page_preview=True`` here.
|
||||
Thin wrapper that goes through the single ``services.telegram_send``
|
||||
entry point so commands and notifications share one routine — same
|
||||
HTTP session pool, same file_id caches.
|
||||
|
||||
Command responses are listings (albums, people, events, ...) that
|
||||
embed multiple links; Telegram's default behavior of rendering a
|
||||
preview of the first URL is almost never what the user wants and
|
||||
clashes with the "Disable link previews" toggle operators set on
|
||||
their Telegram target. We always pass
|
||||
``disable_web_page_preview=True`` here.
|
||||
"""
|
||||
if session is None:
|
||||
from ..services.http_session import get_http_session
|
||||
session = await get_http_session()
|
||||
client = TelegramClient(session, bot_token)
|
||||
result = await client.send_message(
|
||||
chat_id, text,
|
||||
from ..services.telegram_send import send_telegram_message
|
||||
|
||||
result = await send_telegram_message(
|
||||
bot_token, chat_id, text,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
@@ -393,30 +426,28 @@ async def send_media_group(
|
||||
reply_to_message_id: int | None = None,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
) -> None:
|
||||
"""Send media items via TelegramClient.send_notification."""
|
||||
"""Send media items via the shared Telegram routine.
|
||||
|
||||
``media_items`` must already be in TelegramClient asset format — each
|
||||
entry contains ``type`` (``"photo"``/``"video"``/``"document"``),
|
||||
``url``, optional ``cache_key``, and optional ``headers``. Provider
|
||||
command handlers build this format via
|
||||
``build_telegram_asset_entry`` — the same helper the notification
|
||||
dispatcher uses — so videos keep their ``"video"`` type and point at
|
||||
a real video URL instead of a still thumbnail.
|
||||
|
||||
Uses ``services.telegram_send.send_telegram_media`` so the URL cache
|
||||
and asset cache are wired in exactly like the notification path.
|
||||
Repeated ``/latest`` / ``/random`` commands that match previously-sent
|
||||
assets hit the cache and skip the re-upload.
|
||||
"""
|
||||
if not media_items:
|
||||
return
|
||||
|
||||
# Convert command handler media format to TelegramClient asset format
|
||||
assets = []
|
||||
for item in media_items:
|
||||
assets.append({
|
||||
"type": "photo",
|
||||
"url": item.get("thumbnail_url", ""),
|
||||
"cache_key": item.get("asset_id", ""),
|
||||
"headers": {"x-api-key": item.get("api_key", "")},
|
||||
})
|
||||
from ..services.telegram_send import send_telegram_media
|
||||
|
||||
# Build caption from first item
|
||||
captions = [item.get("caption", "") for item in media_items if item.get("caption")]
|
||||
caption = "\n".join(captions) if captions else None
|
||||
|
||||
if session is None:
|
||||
from ..services.http_session import get_http_session
|
||||
session = await get_http_session()
|
||||
client = TelegramClient(session, bot_token)
|
||||
result = await client.send_notification(
|
||||
chat_id, assets=assets, caption=caption,
|
||||
result = await send_telegram_media(
|
||||
bot_token, chat_id, media_items,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
chat_action=None,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,11 @@ import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from notify_bridge_core.providers.immich.asset_utils import get_public_url
|
||||
from notify_bridge_core.notifications.telegram.media import build_telegram_asset_entry
|
||||
from notify_bridge_core.providers.immich.asset_utils import (
|
||||
build_asset_media_urls,
|
||||
get_public_url,
|
||||
)
|
||||
|
||||
from ..handler import _render_cmd_template
|
||||
|
||||
@@ -74,13 +78,16 @@ def build_asset_dict(
|
||||
) -> dict[str, Any]:
|
||||
"""Build a rich asset dict for command templates from an ImmichAssetInfo or raw dict."""
|
||||
if isinstance(asset, dict):
|
||||
# Immich raw search responses nest geo under exifInfo — pull it out so
|
||||
# templates can use flat asset.city / asset.country.
|
||||
exif = asset.get("exifInfo") or {}
|
||||
d = {
|
||||
"id": asset.get("id", ""),
|
||||
"originalFileName": asset.get("originalFileName", asset.get("filename", "")),
|
||||
"type": asset.get("type", "IMAGE"),
|
||||
"createdAt": asset.get("createdAt", asset.get("created_at", asset.get("fileCreatedAt", ""))),
|
||||
"city": asset.get("city", ""),
|
||||
"country": asset.get("country", ""),
|
||||
"city": asset.get("city") or exif.get("city") or "",
|
||||
"country": asset.get("country") or exif.get("country") or "",
|
||||
"is_favorite": asset.get("is_favorite", asset.get("isFavorite", False)),
|
||||
"public_url": asset.get("public_url", public_url),
|
||||
}
|
||||
@@ -123,16 +130,32 @@ def _format_assets(
|
||||
})
|
||||
|
||||
if response_mode == "media":
|
||||
# Reuse the same URL rule (build_asset_media_urls) and entry builder
|
||||
# (build_telegram_asset_entry) as the notification dispatcher so both
|
||||
# paths agree on video → /video/playback and photo → thumbnail. When
|
||||
# these diverged, Telegram rendered a still JPEG for each video in
|
||||
# the media group instead of the real clip.
|
||||
#
|
||||
# We deliberately do NOT pass ``cache_key`` here. TelegramClient
|
||||
# derives it from the URL as ``<host>:<uuid>`` — identical to what
|
||||
# the notification dispatcher produces via extract_asset_id_from_url.
|
||||
# Passing the bare UUID would put command writes in a separate
|
||||
# namespace from notification writes, so neither path could hit the
|
||||
# other's cached file_ids (which is what made the cache look empty
|
||||
# from the WebUI after running /random).
|
||||
media_items: list[dict[str, Any]] = []
|
||||
for asset in assets:
|
||||
asset_id = asset.get("id", "")
|
||||
media_items.append({
|
||||
"type": "photo",
|
||||
"asset_id": asset_id,
|
||||
"caption": "",
|
||||
"thumbnail_url": f"{client.url}/api/assets/{asset_id}/thumbnail?size=preview",
|
||||
"api_key": client.api_key,
|
||||
})
|
||||
asset_type = (asset.get("type") or "").upper()
|
||||
preview_url, _ = build_asset_media_urls(client.url, asset_id, asset_type)
|
||||
entry = build_telegram_asset_entry(
|
||||
url=preview_url,
|
||||
media_type="video" if asset_type == "VIDEO" else "image",
|
||||
api_key=client.api_key,
|
||||
internal_url=client.url,
|
||||
)
|
||||
if entry is not None:
|
||||
media_items.append(entry)
|
||||
# Return text message + media items — text is sent first, media as reply
|
||||
return {"text": text, "media": media_items}
|
||||
|
||||
|
||||
@@ -5,17 +5,14 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from ..handler import _render_cmd_template
|
||||
from .common import _format_assets
|
||||
from .common import _format_assets, build_asset_dict
|
||||
|
||||
|
||||
def _enrich_assets(assets: list[dict[str, Any]], asset_public_urls: dict[str, str]) -> list[dict[str, Any]]:
|
||||
"""Add public_url to assets from the pre-built map. Returns new list without mutating inputs."""
|
||||
if not asset_public_urls:
|
||||
return assets
|
||||
"""Normalize raw Immich assets and attach public_url from the pre-built map."""
|
||||
pub = asset_public_urls or {}
|
||||
return [
|
||||
{**asset, "public_url": asset_public_urls.get(asset.get("id", ""), "")}
|
||||
if asset.get("id", "") in asset_public_urls and not asset.get("public_url")
|
||||
else asset
|
||||
build_asset_dict(asset, public_url=pub.get(asset.get("id", ""), ""))
|
||||
for asset in assets
|
||||
]
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@ from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import TelegramBot, TelegramChat
|
||||
from ..services.telegram import save_chat_from_webhook
|
||||
from ..services.telegram_send import telegram_chat_action
|
||||
from .base import CommandResponse
|
||||
from .handler import handle_command, send_media_group, send_reply
|
||||
from .handler import classify_command_chat_action, handle_command, send_media_group, send_reply
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -95,14 +96,17 @@ async def telegram_webhook(
|
||||
return {"ok": True, "skipped": "commands_disabled"}
|
||||
effective_lang = chat_row.language_override or msg_language
|
||||
message_id = message.get("message_id")
|
||||
responses = await handle_command(bot, chat_id, text, language_code=effective_lang)
|
||||
if responses:
|
||||
for resp in responses:
|
||||
if resp.text:
|
||||
await send_reply(bot_token, chat_id, resp.text, reply_to_message_id=message_id)
|
||||
if resp.media:
|
||||
await send_media_group(bot_token, chat_id, resp.media, reply_to_message_id=message_id)
|
||||
return {"ok": True}
|
||||
async with telegram_chat_action(
|
||||
bot_token, chat_id, classify_command_chat_action(text),
|
||||
):
|
||||
responses = await handle_command(bot, chat_id, text, language_code=effective_lang)
|
||||
if responses:
|
||||
for resp in responses:
|
||||
if resp.text:
|
||||
await send_reply(bot_token, chat_id, resp.text, reply_to_message_id=message_id)
|
||||
if resp.media:
|
||||
await send_media_group(bot_token, chat_id, resp.media, reply_to_message_id=message_id)
|
||||
return {"ok": True}
|
||||
|
||||
return {"ok": True, "skipped": "not_a_command"}
|
||||
|
||||
|
||||
@@ -309,6 +309,14 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
text(f"ALTER TABLE {state_table} ADD COLUMN shared INTEGER DEFAULT 0")
|
||||
)
|
||||
logger.info("Added shared column to %s table", state_table)
|
||||
# meta_fingerprint — small JSON blob captured from the provider's
|
||||
# cheap meta probe. An empty default means "unknown, do a full
|
||||
# fetch next tick" so existing rows don't wrongly skip detection.
|
||||
if not await _has_column(conn, state_table, "meta_fingerprint"):
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE {state_table} ADD COLUMN meta_fingerprint TEXT DEFAULT '{{}}'")
|
||||
)
|
||||
logger.info("Added meta_fingerprint column to %s table", state_table)
|
||||
|
||||
# Add language_code to telegram_chat if missing
|
||||
if await _has_table(conn, "telegram_chat"):
|
||||
|
||||
@@ -376,6 +376,13 @@ class NotificationTrackerState(SQLModel, table=True):
|
||||
shared: bool = Field(default=False)
|
||||
asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
pending_asset_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
# Lightweight fingerprint ({updated_at, asset_count, shared, name, ...})
|
||||
# captured from the provider's cheap meta probe. Letting this differ from
|
||||
# the current provider response is what tells the watcher a full fetch is
|
||||
# actually required — letting it match lets the watcher skip the big read.
|
||||
meta_fingerprint: dict[str, Any] = Field(
|
||||
default_factory=dict, sa_column=Column(JSON)
|
||||
)
|
||||
last_updated: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,49 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_scheduler: AsyncIOScheduler | None = None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adaptive polling (Tier 6 of the big-album optimization plan).
|
||||
#
|
||||
# We don't touch the user-configured ``scan_interval`` — that's still the
|
||||
# authoritative cadence. Instead, we *skip* a growing fraction of scheduled
|
||||
# ticks when a tracker is idle, and reset to 1:1 as soon as it detects
|
||||
# anything. The scheduler keeps running on the user's chosen period, so
|
||||
# response time to the *first* change after an idle stretch is never worse
|
||||
# than one tick — but the steady-state HTTP cost for a fleet of idle
|
||||
# trackers drops by ~75%.
|
||||
#
|
||||
# Thresholds are intentionally conservative: a tracker polling every 30 s
|
||||
# needs 5 min of silence before we halve its effective rate, and 15 min
|
||||
# before we quarter it. Any caller can disable adaptive behavior by passing
|
||||
# ``adaptive=False`` in the tracker filters dict (checked in ``_poll_tracker``).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ADAPTIVE_HALVE_THRESHOLD = 10 # consecutive empty ticks → 1-in-2
|
||||
_ADAPTIVE_QUARTER_THRESHOLD = 30 # consecutive empty ticks → 1-in-4
|
||||
_ADAPTIVE_MAX_SKIP = 4 # hard cap on skip factor
|
||||
|
||||
# Per-tracker adaptive state, keyed by tracker_id. Rebuilt on process
|
||||
# restart — a short warmup period is fine and avoids persisting what is
|
||||
# effectively a performance heuristic.
|
||||
_adaptive_state: dict[int, dict[str, int]] = {}
|
||||
|
||||
|
||||
def _compute_jitter(interval_seconds: int) -> int:
|
||||
"""Return a jitter bound (in seconds) suitable for an IntervalTrigger.
|
||||
|
||||
Without jitter, a fleet of N trackers all on ``scan_interval=60`` wake up
|
||||
at the same wall-clock second every minute — that creates a thundering-
|
||||
herd on the upstream Immich/Gitea/etc. server. APScheduler's ``jitter``
|
||||
randomizes each tick's firing time by ±jitter seconds.
|
||||
|
||||
We use a quarter of the interval up to a 30 s cap. For short intervals
|
||||
(≤8 s) jitter would round to 0 — that's fine, at those cadences a
|
||||
bursty pattern is what the user implicitly opted into.
|
||||
"""
|
||||
if interval_seconds <= 0:
|
||||
return 0
|
||||
return min(interval_seconds // 4, 30)
|
||||
|
||||
|
||||
def get_scheduler() -> AsyncIOScheduler:
|
||||
global _scheduler
|
||||
@@ -271,16 +314,21 @@ async def _load_tracker_jobs() -> None:
|
||||
tracker.id, tracker.name, e,
|
||||
)
|
||||
|
||||
jitter = _compute_jitter(tracker.scan_interval)
|
||||
scheduler.add_job(
|
||||
_poll_tracker,
|
||||
"interval",
|
||||
seconds=tracker.scan_interval,
|
||||
jitter=jitter or None,
|
||||
id=job_id,
|
||||
args=[tracker.id],
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
)
|
||||
_LOGGER.info("Scheduled tracker %d (%s) every %ds", tracker.id, tracker.name, tracker.scan_interval)
|
||||
_LOGGER.info(
|
||||
"Scheduled tracker %d (%s) every %ds (jitter ±%ds)",
|
||||
tracker.id, tracker.name, tracker.scan_interval, jitter,
|
||||
)
|
||||
|
||||
|
||||
def _add_cron_job(
|
||||
@@ -313,6 +361,10 @@ async def schedule_tracker(
|
||||
scheduler = get_scheduler()
|
||||
job_id = f"tracker_{tracker_id}"
|
||||
|
||||
# A reschedule typically follows a config edit or enable/disable flip —
|
||||
# drop adaptive back-off so the first tick after the change runs promptly.
|
||||
reset_adaptive_state(tracker_id)
|
||||
|
||||
# Remove existing job first to allow trigger type changes
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.remove_job(job_id)
|
||||
@@ -324,33 +376,113 @@ async def schedule_tracker(
|
||||
except Exception as e:
|
||||
_LOGGER.error("Invalid cron for tracker %d: %s — using interval", tracker_id, e)
|
||||
|
||||
jitter = _compute_jitter(interval)
|
||||
scheduler.add_job(
|
||||
_poll_tracker,
|
||||
"interval",
|
||||
seconds=interval,
|
||||
jitter=jitter or None,
|
||||
id=job_id,
|
||||
args=[tracker_id],
|
||||
replace_existing=True,
|
||||
)
|
||||
_LOGGER.info("Scheduled tracker %d every %ds", tracker_id, interval)
|
||||
_LOGGER.info(
|
||||
"Scheduled tracker %d every %ds (jitter ±%ds)", tracker_id, interval, jitter,
|
||||
)
|
||||
|
||||
|
||||
async def unschedule_tracker(tracker_id: int) -> None:
|
||||
"""Remove a scheduler job for a tracker."""
|
||||
scheduler = get_scheduler()
|
||||
job_id = f"tracker_{tracker_id}"
|
||||
reset_adaptive_state(tracker_id)
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.remove_job(job_id)
|
||||
_LOGGER.info("Unscheduled tracker %d", tracker_id)
|
||||
|
||||
|
||||
def _adaptive_should_skip(tracker_id: int) -> bool:
|
||||
"""Return True when the adaptive heuristic says to skip this tick.
|
||||
|
||||
Run-length skip: if we're in 1-in-K mode, skip (K-1) ticks between each
|
||||
real poll. Stateless about the *current* tick counter except for the
|
||||
``tick_counter`` we bump here.
|
||||
"""
|
||||
state = _adaptive_state.get(tracker_id)
|
||||
if not state:
|
||||
return False
|
||||
skip_every = state.get("skip_every", 1)
|
||||
if skip_every <= 1:
|
||||
return False
|
||||
state["tick_counter"] = state.get("tick_counter", 0) + 1
|
||||
# Fire on ticks where counter % skip_every == 0; skip the rest.
|
||||
return (state["tick_counter"] % skip_every) != 0
|
||||
|
||||
|
||||
def _adaptive_update(tracker_id: int, events_detected: int) -> None:
|
||||
"""Update the adaptive counter after a real tick ran."""
|
||||
state = _adaptive_state.setdefault(
|
||||
tracker_id, {"empty_count": 0, "skip_every": 1, "tick_counter": 0}
|
||||
)
|
||||
if events_detected > 0:
|
||||
if state["skip_every"] > 1:
|
||||
_LOGGER.info(
|
||||
"Adaptive polling: tracker %d saw activity, restoring base rate",
|
||||
tracker_id,
|
||||
)
|
||||
state["empty_count"] = 0
|
||||
state["skip_every"] = 1
|
||||
state["tick_counter"] = 0
|
||||
return
|
||||
|
||||
state["empty_count"] = state.get("empty_count", 0) + 1
|
||||
if (
|
||||
state["empty_count"] >= _ADAPTIVE_QUARTER_THRESHOLD
|
||||
and state["skip_every"] < _ADAPTIVE_MAX_SKIP
|
||||
):
|
||||
state["skip_every"] = _ADAPTIVE_MAX_SKIP
|
||||
_LOGGER.info(
|
||||
"Adaptive polling: tracker %d idle for %d ticks, skipping 3 of 4",
|
||||
tracker_id, state["empty_count"],
|
||||
)
|
||||
elif (
|
||||
state["empty_count"] >= _ADAPTIVE_HALVE_THRESHOLD
|
||||
and state["skip_every"] < 2
|
||||
):
|
||||
state["skip_every"] = 2
|
||||
_LOGGER.info(
|
||||
"Adaptive polling: tracker %d idle for %d ticks, skipping every other",
|
||||
tracker_id, state["empty_count"],
|
||||
)
|
||||
|
||||
|
||||
def reset_adaptive_state(tracker_id: int) -> None:
|
||||
"""Drop cached adaptive counters for a tracker.
|
||||
|
||||
Used by API callers that make changes requiring the tracker to run
|
||||
promptly on the next scheduled tick (enable/disable, config edits,
|
||||
manual "check now" actions).
|
||||
"""
|
||||
_adaptive_state.pop(tracker_id, None)
|
||||
|
||||
|
||||
async def _poll_tracker(tracker_id: int) -> None:
|
||||
"""Poll a tracker for changes."""
|
||||
from .watcher import check_tracker
|
||||
|
||||
if _adaptive_should_skip(tracker_id):
|
||||
return
|
||||
|
||||
try:
|
||||
await check_tracker(tracker_id)
|
||||
result = await check_tracker(tracker_id)
|
||||
except Exception as e:
|
||||
_LOGGER.error("Error polling tracker %d: %s", tracker_id, e)
|
||||
return
|
||||
|
||||
# Treat the "error" / "skipped" statuses as inconclusive — don't let
|
||||
# a transient upstream failure trick the heuristic into backing off.
|
||||
if isinstance(result, dict) and result.get("status") == "ok":
|
||||
_adaptive_update(tracker_id, int(result.get("events_detected", 0) or 0))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -257,7 +257,13 @@ async def _poll_bot(bot_id: int) -> None:
|
||||
_last_update_id[bot_id] = updates[-1]["update_id"]
|
||||
|
||||
# Process each update
|
||||
from ..commands.handler import handle_command, send_media_group, send_reply
|
||||
from ..commands.handler import (
|
||||
classify_command_chat_action,
|
||||
handle_command,
|
||||
send_media_group,
|
||||
send_reply,
|
||||
)
|
||||
from .telegram_send import telegram_chat_action
|
||||
|
||||
for update in updates:
|
||||
message = update.get("message")
|
||||
@@ -295,13 +301,16 @@ async def _poll_bot(bot_id: int) -> None:
|
||||
continue
|
||||
effective_lang = chat_row.language_override or msg_language
|
||||
message_id = message.get("message_id")
|
||||
responses = await handle_command(bot_obj, chat_id, text, language_code=effective_lang)
|
||||
if responses:
|
||||
for resp in responses:
|
||||
if resp.text:
|
||||
await send_reply(bot_token, chat_id, resp.text, reply_to_message_id=message_id)
|
||||
if resp.media:
|
||||
await send_media_group(bot_token, chat_id, resp.media, reply_to_message_id=message_id)
|
||||
async with telegram_chat_action(
|
||||
bot_token, chat_id, classify_command_chat_action(text),
|
||||
):
|
||||
responses = await handle_command(bot_obj, chat_id, text, language_code=effective_lang)
|
||||
if responses:
|
||||
for resp in responses:
|
||||
if resp.text:
|
||||
await send_reply(bot_token, chat_id, resp.text, reply_to_message_id=message_id)
|
||||
if resp.media:
|
||||
await send_media_group(bot_token, chat_id, resp.media, reply_to_message_id=message_id)
|
||||
except Exception:
|
||||
_LOGGER.error("Error handling command from bot %d", bot_id, exc_info=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Single entry point for all Telegram send operations.
|
||||
|
||||
Both the notification dispatcher (event-driven) and the bot command
|
||||
handlers (user-driven) funnel their Telegram API calls through this
|
||||
module. Keeping construction in one place means:
|
||||
|
||||
* The shared aiohttp session is always reused (one TCP pool for the
|
||||
whole process).
|
||||
* The Telegram file_id caches (URL cache + asset cache) are always
|
||||
wired in, so repeated sends — whether from a scheduled tracker or
|
||||
a ``/latest`` command — reuse cached file_ids instead of re-uploading
|
||||
the same bytes.
|
||||
* Future cross-cutting concerns (rate limiting, telemetry, retries)
|
||||
have exactly one place to live.
|
||||
|
||||
The actual Telegram API routine is still ``TelegramClient`` in core —
|
||||
this module just guarantees every caller gets a properly-wired client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from typing import Any, AsyncIterator, Callable
|
||||
|
||||
import aiohttp
|
||||
|
||||
from notify_bridge_core.notifications.telegram.client import (
|
||||
NotificationResult,
|
||||
TelegramClient,
|
||||
)
|
||||
|
||||
from .http_session import get_http_session
|
||||
from .watcher import _get_telegram_caches
|
||||
|
||||
|
||||
async def get_telegram_client(
|
||||
bot_token: str,
|
||||
*,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
thumbhash_resolver: Callable[[str], str | None] | None = None,
|
||||
) -> TelegramClient:
|
||||
"""Return a ``TelegramClient`` wired to shared session + shared caches.
|
||||
|
||||
Every Telegram send in the process should acquire its client from
|
||||
here — constructing ``TelegramClient`` directly skips the caches and
|
||||
silently halves cache hit rate.
|
||||
|
||||
Args:
|
||||
bot_token: The bot's API token.
|
||||
session: Optional explicit aiohttp session. Defaults to the
|
||||
process-wide shared session.
|
||||
thumbhash_resolver: Optional asset-id → thumbhash lookup. The
|
||||
notification dispatcher passes one so asset-cache entries
|
||||
invalidate on visual change; the command path doesn't need it
|
||||
(commands always ask for a fresh result).
|
||||
"""
|
||||
if session is None:
|
||||
session = await get_http_session()
|
||||
url_cache, asset_cache = await _get_telegram_caches()
|
||||
return TelegramClient(
|
||||
session, bot_token,
|
||||
url_cache=url_cache,
|
||||
asset_cache=asset_cache,
|
||||
thumbhash_resolver=thumbhash_resolver,
|
||||
)
|
||||
|
||||
|
||||
async def send_telegram_message(
|
||||
bot_token: str,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
*,
|
||||
reply_to_message_id: int | None = None,
|
||||
disable_web_page_preview: bool = True,
|
||||
parse_mode: str = "HTML",
|
||||
) -> NotificationResult:
|
||||
"""Send a plain-text Telegram message with caches wired in."""
|
||||
client = await get_telegram_client(bot_token)
|
||||
return await client.send_message(
|
||||
chat_id, text,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
|
||||
|
||||
async def send_telegram_media(
|
||||
bot_token: str,
|
||||
chat_id: str,
|
||||
assets: list[dict[str, Any]],
|
||||
*,
|
||||
caption: str | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
max_group_size: int = 10,
|
||||
chunk_delay: int = 0,
|
||||
max_asset_data_size: int | None = None,
|
||||
send_large_photos_as_documents: bool = False,
|
||||
chat_action: str | None = "typing",
|
||||
thumbhash_resolver: Callable[[str], str | None] | None = None,
|
||||
) -> NotificationResult:
|
||||
"""Send a Telegram media group (or single asset) with caches wired in.
|
||||
|
||||
``assets`` must be in ``TelegramClient`` format — see
|
||||
``notify_bridge_core.notifications.telegram.media.build_telegram_asset_entry``
|
||||
for the canonical builder.
|
||||
"""
|
||||
client = await get_telegram_client(
|
||||
bot_token, thumbhash_resolver=thumbhash_resolver,
|
||||
)
|
||||
return await client.send_notification(
|
||||
chat_id,
|
||||
assets=assets,
|
||||
caption=caption,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
max_group_size=max_group_size,
|
||||
chunk_delay=chunk_delay,
|
||||
max_asset_data_size=max_asset_data_size,
|
||||
send_large_photos_as_documents=send_large_photos_as_documents,
|
||||
chat_action=chat_action,
|
||||
)
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def telegram_chat_action(
|
||||
bot_token: str,
|
||||
chat_id: str,
|
||||
action: str | None,
|
||||
) -> AsyncIterator[None]:
|
||||
"""Hold a Telegram chat action (e.g. ``upload_photo``) for the block's duration.
|
||||
|
||||
Used by the command path to show ``typing`` / ``uploading photo`` while
|
||||
the bot fetches assets from the service (Immich, etc.) AND uploads them
|
||||
to Telegram — i.e. for the whole user-visible wait, not just the upload.
|
||||
|
||||
A ``None`` action makes this a no-op so callers don't have to branch.
|
||||
"""
|
||||
if not action:
|
||||
yield
|
||||
return
|
||||
|
||||
client = await get_telegram_client(bot_token)
|
||||
task = client.start_chat_action_keepalive(chat_id, action)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
@@ -35,8 +35,34 @@ _asset_cache: TelegramFileCache | None = None
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _load_cache_settings() -> tuple[int, int]:
|
||||
"""Return (url_ttl_seconds, asset_max_entries) from app settings.
|
||||
|
||||
Defaults apply when the settings rows are missing. Reads in a short-lived
|
||||
session to avoid coupling to the caller's transaction.
|
||||
"""
|
||||
from ..api.app_settings import get_setting
|
||||
async with AsyncSession(get_engine()) as session:
|
||||
ttl_hours_str = await get_setting(session, "telegram_cache_ttl_hours")
|
||||
max_entries_str = await get_setting(session, "telegram_asset_cache_max_entries")
|
||||
try:
|
||||
ttl_hours = int(ttl_hours_str) if ttl_hours_str else 720
|
||||
except ValueError:
|
||||
ttl_hours = 720
|
||||
try:
|
||||
max_entries = int(max_entries_str) if max_entries_str else 5000
|
||||
except ValueError:
|
||||
max_entries = 5000
|
||||
return ttl_hours * 3600, max_entries
|
||||
|
||||
|
||||
async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFileCache | None]:
|
||||
"""Lazily initialize shared Telegram file caches using NOTIFY_BRIDGE_DATA_DIR."""
|
||||
"""Lazily initialize shared Telegram file caches using NOTIFY_BRIDGE_DATA_DIR.
|
||||
|
||||
The URL cache runs in TTL mode (URLs aren't content-addressable); the asset
|
||||
cache runs in thumbhash mode so entries invalidate on visual change rather
|
||||
than age. Both honor an LRU size cap from settings.
|
||||
"""
|
||||
global _url_cache, _asset_cache
|
||||
if _url_cache is not None:
|
||||
return _url_cache, _asset_cache
|
||||
@@ -50,16 +76,91 @@ async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFile
|
||||
if not data_dir:
|
||||
return None, None
|
||||
cache_dir = Path(data_dir) / "cache"
|
||||
url_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_url_cache.json"))
|
||||
asset_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_asset_cache.json"))
|
||||
ttl_seconds, max_entries = await _load_cache_settings()
|
||||
url_cache = TelegramFileCache(
|
||||
JsonFileBackend(cache_dir / "telegram_url_cache.json"),
|
||||
ttl_seconds=ttl_seconds,
|
||||
max_entries=max_entries,
|
||||
)
|
||||
asset_cache = TelegramFileCache(
|
||||
JsonFileBackend(cache_dir / "telegram_asset_cache.json"),
|
||||
use_thumbhash=True,
|
||||
max_entries=max_entries,
|
||||
)
|
||||
await url_cache.async_load()
|
||||
await asset_cache.async_load()
|
||||
_url_cache = url_cache
|
||||
_asset_cache = asset_cache
|
||||
_LOGGER.info("Initialized Telegram file caches in %s", cache_dir)
|
||||
_LOGGER.info(
|
||||
"Initialized Telegram caches in %s (url ttl=%ds, max_entries=%d, asset thumbhash mode)",
|
||||
cache_dir, ttl_seconds, max_entries,
|
||||
)
|
||||
return _url_cache, _asset_cache
|
||||
|
||||
|
||||
async def reset_telegram_caches_in_memory() -> None:
|
||||
"""Drop in-memory cache refs without touching files on disk.
|
||||
|
||||
Used after settings changes so the next dispatch re-initializes caches
|
||||
with fresh parameters. Contrast with ``clear_telegram_caches`` which also
|
||||
deletes cached file_ids.
|
||||
"""
|
||||
global _url_cache, _asset_cache
|
||||
async with _cache_lock:
|
||||
_url_cache = None
|
||||
_asset_cache = None
|
||||
_LOGGER.info("Reset Telegram cache refs in memory (files preserved)")
|
||||
|
||||
|
||||
async def get_telegram_cache_stats() -> dict[str, Any]:
|
||||
"""Return stats for the URL and asset Telegram caches.
|
||||
|
||||
Loads caches lazily if they haven't been touched by a dispatch yet.
|
||||
Returns zero-counts when ``NOTIFY_BRIDGE_DATA_DIR`` is not configured.
|
||||
"""
|
||||
url_cache, asset_cache = await _get_telegram_caches()
|
||||
empty = {"count": 0, "total_size_bytes": 0, "oldest": None, "newest": None}
|
||||
return {
|
||||
"url": url_cache.stats() if url_cache else empty,
|
||||
"asset": asset_cache.stats() if asset_cache else empty,
|
||||
}
|
||||
|
||||
|
||||
async def clear_telegram_caches() -> dict[str, Any]:
|
||||
"""Delete both Telegram file caches from disk and reset in-memory state.
|
||||
|
||||
Next dispatch re-initializes the caches via `_get_telegram_caches()`.
|
||||
Returns a summary with the paths that were removed.
|
||||
"""
|
||||
global _url_cache, _asset_cache
|
||||
async with _cache_lock:
|
||||
removed: list[str] = []
|
||||
for cache, label in ((_url_cache, "url"), (_asset_cache, "asset")):
|
||||
if cache is not None:
|
||||
await cache.async_remove()
|
||||
removed.append(label)
|
||||
|
||||
# Also remove files from disk in case caches were never initialized
|
||||
# in this process (data_dir set but dispatch never ran).
|
||||
import os
|
||||
from pathlib import Path
|
||||
data_dir = os.environ.get("NOTIFY_BRIDGE_DATA_DIR")
|
||||
if data_dir:
|
||||
cache_dir = Path(data_dir) / "cache"
|
||||
for name in ("telegram_url_cache.json", "telegram_asset_cache.json"):
|
||||
path = cache_dir / name
|
||||
if path.exists():
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError as e:
|
||||
_LOGGER.warning("Failed to remove %s: %s", path, e)
|
||||
|
||||
_url_cache = None
|
||||
_asset_cache = None
|
||||
_LOGGER.info("Cleared Telegram file caches: %s", removed or "none in memory")
|
||||
return {"cleared": True, "removed": removed}
|
||||
|
||||
|
||||
async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
"""Poll a tracker's provider for changes and dispatch notifications."""
|
||||
engine = get_engine()
|
||||
@@ -86,8 +187,17 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
"asset_ids": s.asset_ids,
|
||||
"pending_asset_ids": s.pending_asset_ids,
|
||||
"shared": bool(s.shared),
|
||||
"meta_fingerprint": s.meta_fingerprint or {},
|
||||
}
|
||||
|
||||
# Snapshot the original fingerprint per collection so we can skip the
|
||||
# (expensive) asset_ids rewrite when nothing changed. For a 200k-asset
|
||||
# album this avoids a ~7 MB JSON write to the state row every tick.
|
||||
original_fingerprints: dict[str, dict[str, Any]] = {
|
||||
cid: dict(cstate.get("meta_fingerprint") or {})
|
||||
for cid, cstate in state_dict.items()
|
||||
}
|
||||
|
||||
# Load tracker-target links
|
||||
link_data = await load_link_data(session, tracker_id)
|
||||
|
||||
@@ -178,11 +288,20 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
existing = s
|
||||
break
|
||||
|
||||
current_fingerprint = dict(cstate.get("meta_fingerprint") or {})
|
||||
prior_fingerprint = original_fingerprints.get(cid, {})
|
||||
# Skip the DB update when the provider reported no meaningful
|
||||
# change. ``existing`` is None on first-ever fetch for a
|
||||
# collection — that path always writes so the row gets created.
|
||||
if existing is not None and current_fingerprint == prior_fingerprint:
|
||||
continue
|
||||
|
||||
if existing:
|
||||
existing.asset_ids = cstate.get("asset_ids", [])
|
||||
existing.pending_asset_ids = cstate.get("pending_asset_ids", [])
|
||||
existing.collection_name = cstate.get("name", "")
|
||||
existing.shared = cstate.get("shared", False)
|
||||
existing.meta_fingerprint = current_fingerprint
|
||||
session.add(existing)
|
||||
else:
|
||||
new_ts = NotificationTrackerState(
|
||||
@@ -192,6 +311,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
shared=cstate.get("shared", False),
|
||||
asset_ids=cstate.get("asset_ids", []),
|
||||
pending_asset_ids=cstate.get("pending_asset_ids", []),
|
||||
meta_fingerprint=current_fingerprint,
|
||||
)
|
||||
session.add(new_ts)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user