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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user