feat(cache): thumbhash-validated asset cache + settings UX overhaul
Cache engine: - TelegramFileCache: configurable max_entries (LRU cap applies in both TTL and thumbhash modes), ttl_seconds<=0 disables TTL, stats() method. - Dispatcher builds an asset.id -> thumbhash resolver from event.added_assets (Immich populates thumbhash in extra) and passes it to TelegramClient, so asset-cache entries invalidate on visual change rather than age. - Watcher wires app settings into cache init: URL cache = TTL + LRU cap, asset cache = thumbhash + LRU cap. Adds soft-reset (in-memory only) used when cache params change. Settings: - New key telegram_asset_cache_max_entries (default 5000). - telegram_cache_ttl_hours default bumped 48 -> 720 (30d); now URL-only. - PUT /settings resets in-memory caches when cache keys change (files kept). - New endpoints: GET/POST /settings/telegram-cache/stats and /clear. Settings page: - Cache stats card (count + size + oldest/newest per bucket) with a hint explaining that the size is cumulative uploaded-to-Telegram bytes. - Clear-cache button behind a confirm modal. - New TimezoneSelector + LocaleSelector components replace raw inputs. - max-entries input, TTL range updated (0..8760, 0 = disabled). Mobile nav: - "More" panel now mirrors the full sidebar tree (groups + subnodes) so every destination is reachable on mobile; previously flat hand-picked list. - Nav height uses env(safe-area-inset-bottom); panel bottom + z-index fixed so content can't visually overlay the bottom bar. A11y / DOM warnings: - Password-change form has a hidden username field for password-manager association; autocomplete hints on all three password inputs. - Telegram webhook secret wrapped in a no-op form + autocomplete=off. Bug fix: - update_settings used any(await ... for ...) which raised TypeError at runtime (async generator not an iterator); replaced with explicit loop.
This commit is contained in:
@@ -294,10 +294,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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user