from __future__ import annotations import asyncio import hashlib import json import os import shutil import time import weakref from collections import OrderedDict from collections.abc import Awaitable, Callable, Iterable from contextvars import ContextVar, Token from pathlib import Path from typing import Any from astrbot.api import logger from .sqlite import AsyncSQLiteDB API_ENDPOINTS: tuple[str, ...] = ( "/active/calendar", "/active/celebs", "/arena/awesome", "/arena/recent", "/arena/schools", "/auction/records", "/battle/records", "/card/cached", "/card/random", "/card/records", "/castle/status", "/chat/records", "/chitu/records", "/chitu/week/records", "/duowan/statistics", "/event/collect", "/event/missing", "/event/recent", "/event/records", "/event/statistics", "/exam/search", "/fenxian/records", "/firework/records", "/food/list", "/fraud/detail", "/home/flower", "/home/furniture", "/home/travel", "/mech/decrypt", "/mentor/search", "/monster/records", "/monster/weekly", "/news/announce", "/news/records", "/raid/records", "/ranch/chat", "/ranch/records", "/rank/arena", "/rank/championship", "/rank/constable", "/rank/outlaw", "/rank/statistics", "/rank/trials", "/rank/wanted", "/recruit/search", "/reward/statistics", "/role/achievement", "/role/detail", "/sand/records", "/saohua/answer", "/saohua/content", "/saohua/context", "/saohua/drink", "/saohua/eat", "/saohua/random", "/saohua/zhanan", "/school/matrix", "/school/seniority", "/school/skills", "/school/talent", "/server/status/check", "/skill/rework", "/steed/records", "/tieba/item/records", "/tieba/random", "/trade/demon", "/trade/manufacture", "/trade/records", "/trade/wanbaolou", "/tuilan/achievement", "/wicked/records", ) class CacheService: """持久化接口 JSON 与 HTML 渲染图片,并提供 WebUI 配置。""" DEFAULT_API_TTL = 300 DEFAULT_IMAGE_TTL = 600 MAX_TTL_SECONDS = 30 * 24 * 60 * 60 DEFAULT_MAX_MEMORY_ENTRIES = 256 DEFAULT_MAX_IMAGE_BYTES = 512 * 1024 * 1024 MAX_MEMORY_ENTRIES_LIMIT = 100_000 MAX_IMAGE_MB_LIMIT = 10_240 STALE_RETENTION_SECONDS = 7 * 24 * 60 * 60 _SENSITIVE_KEYS = frozenset( {"token", "ticket", "authorization", "access_token", "jx3api_token"} ) _NO_CACHE_API_DEFAULTS = frozenset( { "/card/random", "/saohua/answer", "/saohua/content", "/saohua/context", "/saohua/drink", "/saohua/eat", "/saohua/random", "/saohua/zhanan", "/tieba/random", } ) def __init__( self, sqlite: AsyncSQLiteDB, image_dir: Path, asset_roots: Iterable[Path] = (), ): self._sqlite = sqlite self.image_dir = Path(image_dir) self._settings: dict[tuple[str, str], int] = {} self.max_memory_entries = self.DEFAULT_MAX_MEMORY_ENTRIES self.max_image_bytes = self.DEFAULT_MAX_IMAGE_BYTES self._memory: OrderedDict[str, tuple[int, int, str]] = OrderedDict() self._api_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = ( weakref.WeakValueDictionary() ) self._image_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = ( weakref.WeakValueDictionary() ) self._command_context: ContextVar[tuple[str, str]] = ContextVar( "jx3_cache_command_context", default=("", ""), ) self._image_names: set[str] = set() self._asset_signature = self._build_asset_signature(asset_roots) @staticmethod def _build_asset_signature(roots: Iterable[Path]) -> str: parts: list[str] = [] for root in roots: path = Path(root) if not path.exists(): continue for item in sorted( candidate for candidate in path.rglob("*") if candidate.is_file() ): try: stat = item.stat() except OSError: continue parts.append( f"{item.relative_to(path)}:{stat.st_size}:{stat.st_mtime_ns}" ) return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest() async def initialize(self): self.image_dir.mkdir(parents=True, exist_ok=True) await self._sqlite.execute( """ CREATE TABLE IF NOT EXISTS cache_settings( cache_type TEXT NOT NULL, cache_name TEXT NOT NULL, ttl_seconds INTEGER NOT NULL, PRIMARY KEY(cache_type, cache_name) ) """ ) await self._sqlite.execute( """ CREATE TABLE IF NOT EXISTS cache_limits( limit_name TEXT PRIMARY KEY, limit_value INTEGER NOT NULL ) """ ) await self._sqlite.execute( """ CREATE TABLE IF NOT EXISTS api_response_cache( cache_key TEXT PRIMARY KEY, endpoint TEXT NOT NULL, payload TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, last_accessed_at INTEGER NOT NULL ) """ ) await self._sqlite.execute( """ CREATE TABLE IF NOT EXISTS image_render_cache( cache_key TEXT PRIMARY KEY, cache_name TEXT NOT NULL, file_name TEXT NOT NULL, size_bytes INTEGER NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, last_accessed_at INTEGER NOT NULL, message_text TEXT NOT NULL DEFAULT '' ) """ ) image_columns = await self._sqlite.fetch_all( "PRAGMA table_info(image_render_cache)" ) if "message_text" not in {str(row.get("name")) for row in image_columns}: await self._sqlite.execute( """ ALTER TABLE image_render_cache ADD COLUMN message_text TEXT NOT NULL DEFAULT '' """ ) await self._sqlite.execute( "CREATE INDEX IF NOT EXISTS idx_api_cache_endpoint ON api_response_cache(endpoint)" ) await self._sqlite.execute( "CREATE INDEX IF NOT EXISTS idx_image_cache_name ON image_render_cache(cache_name)" ) await self._load_settings() await self._load_limits() await self.cleanup_expired() self._enforce_memory_limit() await self._enforce_image_limit() async def _load_settings(self): rows = await self._sqlite.select_all("cache_settings") self._settings = { (str(row["cache_type"]), str(row["cache_name"])): int(row["ttl_seconds"]) for row in rows } async def _load_limits(self): rows = await self._sqlite.select_all("cache_limits") limits = {str(row["limit_name"]): int(row["limit_value"]) for row in rows} self.max_memory_entries = self._validated_memory_limit( limits.get("api_memory_entries", self.DEFAULT_MAX_MEMORY_ENTRIES) ) image_limit_mb = self._validated_image_limit_mb( limits.get("image_max_mb", self.DEFAULT_MAX_IMAGE_BYTES // 1024 // 1024) ) self.max_image_bytes = image_limit_mb * 1024 * 1024 @classmethod def _validated_memory_limit(cls, value: Any) -> int: if isinstance(value, bool): raise ValueError("接口内存缓存条数必须是整数") try: limit = int(value) except (TypeError, ValueError) as exc: raise ValueError("接口内存缓存条数必须是整数") from exc if limit < 1 or limit > cls.MAX_MEMORY_ENTRIES_LIMIT: raise ValueError("接口内存缓存条数必须在 1 到 100000 之间") return limit @classmethod def _validated_image_limit_mb(cls, value: Any) -> int: if isinstance(value, bool): raise ValueError("图片缓存容量必须是整数 MB") try: limit = int(value) except (TypeError, ValueError) as exc: raise ValueError("图片缓存容量必须是整数 MB") from exc if limit < 1 or limit > cls.MAX_IMAGE_MB_LIMIT: raise ValueError("图片缓存容量必须在 1 到 10240 MB 之间") return limit async def set_limits(self, api_memory_entries: Any, image_max_mb: Any): memory_limit = self._validated_memory_limit(api_memory_entries) image_limit_mb = self._validated_image_limit_mb(image_max_mb) for limit_name, limit_value in ( ("api_memory_entries", memory_limit), ("image_max_mb", image_limit_mb), ): await self._sqlite.execute( """ INSERT INTO cache_limits(limit_name, limit_value) VALUES(?, ?) ON CONFLICT(limit_name) DO UPDATE SET limit_value=excluded.limit_value """, (limit_name, limit_value), ) self.max_memory_entries = memory_limit self.max_image_bytes = image_limit_mb * 1024 * 1024 self._enforce_memory_limit() await self._enforce_image_limit() def register_image_names(self, names: Iterable[str]): self._image_names.update( str(name).strip() for name in names if str(name).strip() ) def enter_command(self, command_name: str, args: Iterable[Any] = ()) -> Token: argument_signature = hashlib.sha256( self._json(list(args)).encode("utf-8") ).hexdigest() return self._command_context.set( (str(command_name or "").strip(), argument_signature) ) def leave_command(self, token: Token): self._command_context.reset(token) def current_command(self) -> str: return self._command_context.get()[0] def current_command_signature(self) -> str: return self._command_context.get()[1] def _base_ttl(self, cache_type: str, cache_name: str) -> int: if cache_type == "api" and cache_name in self._NO_CACHE_API_DEFAULTS: return 0 # 会话避雷图片默认不缓存,避免修改记录后仍展示旧图;仍可在 WebUI 单独开启。 if cache_type == "image" and cache_name in {"避雷查看", "避雷查询"}: return 0 return self.DEFAULT_API_TTL if cache_type == "api" else self.DEFAULT_IMAGE_TTL def get_ttl(self, cache_type: str, cache_name: str) -> int: specific = self._settings.get((cache_type, cache_name)) if specific is not None: return specific if cache_type == "api" and cache_name in self._NO_CACHE_API_DEFAULTS: return 0 if cache_type == "image" and cache_name in {"避雷查看", "避雷查询"}: return 0 default = self._settings.get((cache_type, "*")) if default is not None: return default return self._base_ttl(cache_type, cache_name) @classmethod def _validate_ttl(cls, value: Any) -> int: if isinstance(value, bool): raise ValueError("缓存时间必须是整数秒") try: ttl = int(value) except (TypeError, ValueError) as exc: raise ValueError("缓存时间必须是整数秒") from exc if ttl < 0 or ttl > cls.MAX_TTL_SECONDS: raise ValueError("缓存时间必须在 0 到 2592000 秒之间") return ttl async def set_ttl( self, cache_type: str, cache_name: str, ttl_seconds: Any = None, inherit: bool = False, ): if cache_type not in {"api", "image"}: raise ValueError("缓存类型仅支持 api 或 image") cache_name = str(cache_name or "").strip() if not cache_name: raise ValueError("缓存项目不能为空") if cache_name == "*" and inherit: raise ValueError("默认缓存时间不能继承") if inherit: await self._sqlite.delete( "cache_settings", "cache_type=? AND cache_name=?", (cache_type, cache_name), ) self._settings.pop((cache_type, cache_name), None) return ttl = self._validate_ttl(ttl_seconds) await self._sqlite.execute( """ INSERT INTO cache_settings(cache_type, cache_name, ttl_seconds) VALUES(?, ?, ?) ON CONFLICT(cache_type, cache_name) DO UPDATE SET ttl_seconds=excluded.ttl_seconds """, (cache_type, cache_name, ttl), ) self._settings[(cache_type, cache_name)] = ttl @classmethod def _normalized(cls, value: Any, strip_sensitive: bool = False) -> Any: if isinstance(value, dict): return { str(key): cls._normalized(item, strip_sensitive) for key, item in sorted(value.items(), key=lambda pair: str(pair[0])) if not strip_sensitive or str(key).lower() not in cls._SENSITIVE_KEYS } if isinstance(value, (list, tuple)): return [cls._normalized(item, strip_sensitive) for item in value] if isinstance(value, (str, int, float, bool)) or value is None: return value return str(value) @classmethod def _json(cls, value: Any, strip_sensitive: bool = False) -> str: return json.dumps( cls._normalized(value, strip_sensitive), ensure_ascii=False, sort_keys=True, separators=(",", ":"), ) @classmethod def build_api_key(cls, endpoint: str, params: dict[str, Any]) -> str: source = f"api:v1|{endpoint}|{cls._json(params, strip_sensitive=True)}" return hashlib.sha256(source.encode("utf-8")).hexdigest() async def _read_api_payload( self, cache_key: str, endpoint: str, allow_expired: bool = False, ) -> tuple[Any | None, int | None, int | None]: now = int(time.time()) ttl = self.get_ttl("api", endpoint) memory = self._memory.get(cache_key) if memory is not None: created_at, expires_at, payload = memory effective_expiry = min(expires_at, created_at + ttl) if allow_expired and effective_expiry <= now - self.STALE_RETENTION_SECONDS: self._memory.pop(cache_key, None) elif allow_expired or effective_expiry > now: self._memory.move_to_end(cache_key) try: return json.loads(payload), effective_expiry, created_at except json.JSONDecodeError: self._memory.pop(cache_key, None) row = await self._sqlite.fetch_one( """ SELECT payload, created_at, expires_at FROM api_response_cache WHERE cache_key=? AND endpoint=? """, (cache_key, endpoint), ) if not row: return None, None, None expires_at = int(row["expires_at"]) created_at = int(row["created_at"]) effective_expiry = min(expires_at, created_at + ttl) if not allow_expired and effective_expiry <= now: return None, effective_expiry, created_at if allow_expired and effective_expiry <= now - self.STALE_RETENTION_SECONDS: await self._sqlite.delete("api_response_cache", "cache_key=?", (cache_key,)) return None, effective_expiry, created_at try: data = json.loads(str(row["payload"])) except json.JSONDecodeError: await self._sqlite.delete("api_response_cache", "cache_key=?", (cache_key,)) return None, None, None self._remember(cache_key, created_at, expires_at, str(row["payload"])) await self._sqlite.execute( "UPDATE api_response_cache SET last_accessed_at=? WHERE cache_key=?", (now, cache_key), ) return data, effective_expiry, created_at def _remember(self, cache_key: str, created_at: int, expires_at: int, payload: str): self._memory[cache_key] = (created_at, expires_at, payload) self._memory.move_to_end(cache_key) self._enforce_memory_limit() def _enforce_memory_limit(self): while len(self._memory) > self.max_memory_entries: self._memory.popitem(last=False) async def _save_api_payload( self, cache_key: str, endpoint: str, data: Any, ttl_seconds: int, ) -> int: payload = self._json(data) now = int(time.time()) expires_at = now + ttl_seconds await self._sqlite.execute( """ INSERT INTO api_response_cache( cache_key, endpoint, payload, created_at, expires_at, last_accessed_at ) VALUES(?, ?, ?, ?, ?, ?) ON CONFLICT(cache_key) DO UPDATE SET endpoint=excluded.endpoint, payload=excluded.payload, created_at=excluded.created_at, expires_at=excluded.expires_at, last_accessed_at=excluded.last_accessed_at """, (cache_key, endpoint, payload, now, expires_at, now), ) self._remember(cache_key, now, expires_at, payload) return now async def request_api( self, endpoint: str, params: dict[str, Any], requester: Callable[[], Awaitable[Any]], is_cacheable: Callable[[Any], bool], force_refresh: bool = False, allow_stale: bool = True, ) -> tuple[Any, dict[str, Any]]: ttl = self.get_ttl("api", endpoint) cache_key = self.build_api_key(endpoint, params) metadata = { "endpoint": endpoint, "cache_key": cache_key, "hit": False, "stale": False, "ttl_seconds": ttl, "data_hash": "", "created_at": None, } if ttl <= 0: data = await requester() metadata["created_at"] = int(time.time()) if is_cacheable(data): metadata["data_hash"] = hashlib.sha256( self._json(data).encode("utf-8") ).hexdigest() return data, metadata if not force_refresh: cached, _, created_at = await self._read_api_payload(cache_key, endpoint) if cached is not None: metadata["hit"] = True metadata["created_at"] = created_at metadata["data_hash"] = hashlib.sha256( self._json(cached).encode("utf-8") ).hexdigest() return cached, metadata lock = self._api_locks.setdefault(cache_key, asyncio.Lock()) async with lock: if not force_refresh: cached, _, created_at = await self._read_api_payload(cache_key, endpoint) if cached is not None: metadata["hit"] = True metadata["created_at"] = created_at metadata["data_hash"] = hashlib.sha256( self._json(cached).encode("utf-8") ).hexdigest() return cached, metadata stale, _, stale_created_at = await self._read_api_payload( cache_key, endpoint, allow_expired=True, ) data = await requester() if is_cacheable(data): metadata["data_hash"] = hashlib.sha256( self._json(data).encode("utf-8") ).hexdigest() try: metadata["created_at"] = await self._save_api_payload( cache_key, endpoint, data, ttl, ) except Exception as exc: metadata["created_at"] = int(time.time()) logger.warning(f"写入接口缓存失败 endpoint={endpoint}: {exc}") return data, metadata if stale is not None and allow_stale: metadata["hit"] = True metadata["stale"] = True metadata["created_at"] = stale_created_at metadata["data_hash"] = hashlib.sha256( self._json(stale).encode("utf-8") ).hexdigest() logger.warning(f"JX3API 请求失败,使用过期缓存:{endpoint}") return stale, metadata return data, metadata def build_image_key( self, cache_name: str, template: str, data: dict[str, Any], render_options: dict[str, Any], source_signature: str = "", variant_signature: str = "", ) -> str: source = "|".join( ( "image:v2", cache_name, hashlib.sha256(template.encode("utf-8")).hexdigest(), source_signature or hashlib.sha256(self._json(data).encode("utf-8")).hexdigest(), variant_signature, self._json(render_options), self._asset_signature, ) ) return hashlib.sha256(source.encode("utf-8")).hexdigest() @classmethod def value_signature(cls, value: Any) -> str: return hashlib.sha256(cls._json(value).encode("utf-8")).hexdigest() def build_image_request_key( self, cache_name: str, render_options: dict[str, Any], variant_signature: str, scope_signature: str = "", ) -> str: """生成可在请求接口前计算的最终图片缓存键。""" source = "|".join( ( "image-request:v2", cache_name, variant_signature, scope_signature, self._json(render_options), self._asset_signature, ) ) return hashlib.sha256(source.encode("utf-8")).hexdigest() def image_lock(self, cache_key: str) -> asyncio.Lock: return self._image_locks.setdefault(cache_key, asyncio.Lock()) async def get_image_entry( self, cache_key: str, cache_name: str, ) -> tuple[Path, str] | None: try: ttl = self.get_ttl("image", cache_name) if ttl <= 0: return None now = int(time.time()) row = await self._sqlite.fetch_one( """ SELECT file_name, created_at, expires_at, message_text FROM image_render_cache WHERE cache_key=? AND cache_name=? """, (cache_key, cache_name), ) if not row: return None effective_expiry = min(int(row["expires_at"]), int(row["created_at"]) + ttl) path = self.image_dir / str(row["file_name"]) if effective_expiry <= now or not path.is_file(): await self._delete_image_record(cache_key, path) return None await self._sqlite.execute( "UPDATE image_render_cache SET last_accessed_at=? WHERE cache_key=?", (now, cache_key), ) return path, str(row.get("message_text") or "") except Exception as exc: logger.warning(f"读取图片缓存失败 cache={cache_name}: {exc}") return None async def get_image(self, cache_key: str, cache_name: str) -> Path | None: entry = await self.get_image_entry(cache_key, cache_name) return entry[0] if entry else None async def save_image( self, cache_key: str, cache_name: str, source_path: str, image_format: str, message_text: str = "", ) -> Path | None: ttl = self.get_ttl("image", cache_name) source = Path(source_path) if ttl <= 0 or not source.is_file(): return None extension = "jpg" if image_format == "jpeg" else "png" file_name = f"{cache_key}.{extension}" target = self.image_dir / file_name temporary = self.image_dir / f".{file_name}.tmp" try: await asyncio.to_thread(shutil.copy2, source, temporary) await asyncio.to_thread(os.replace, temporary, target) size_bytes = target.stat().st_size now = int(time.time()) await self._sqlite.execute( """ INSERT INTO image_render_cache( cache_key, cache_name, file_name, size_bytes, created_at, expires_at, last_accessed_at, message_text ) VALUES(?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(cache_key) DO UPDATE SET cache_name=excluded.cache_name, file_name=excluded.file_name, size_bytes=excluded.size_bytes, created_at=excluded.created_at, expires_at=excluded.expires_at, last_accessed_at=excluded.last_accessed_at, message_text=excluded.message_text """, ( cache_key, cache_name, file_name, size_bytes, now, now + ttl, now, str(message_text or ""), ), ) await self._enforce_image_limit() return target except Exception as exc: logger.warning(f"保存图片缓存失败 cache={cache_name}: {exc}") for path in (temporary, target): try: if path.exists(): path.unlink() except OSError: pass return None async def _delete_image_record(self, cache_key: str, path: Path): await self._sqlite.delete("image_render_cache", "cache_key=?", (cache_key,)) try: if path.is_file() and path.parent.resolve() == self.image_dir.resolve(): path.unlink() except OSError: pass async def _enforce_image_limit(self): row = await self._sqlite.fetch_one( "SELECT COALESCE(SUM(size_bytes), 0) AS total FROM image_render_cache" ) total = int((row or {}).get("total") or 0) if total <= self.max_image_bytes: return rows = await self._sqlite.fetch_all( """ SELECT cache_key, file_name, size_bytes FROM image_render_cache ORDER BY last_accessed_at ASC """ ) for item in rows: if total <= self.max_image_bytes: break await self._delete_image_record( str(item["cache_key"]), self.image_dir / str(item["file_name"]), ) total -= int(item["size_bytes"]) async def cleanup_expired(self): now = int(time.time()) self._memory = OrderedDict( (key, value) for key, value in self._memory.items() if value[1] > now ) await self._sqlite.delete( "api_response_cache", "expires_at<=?", (now - self.STALE_RETENTION_SECONDS,), ) rows = await self._sqlite.fetch_all( "SELECT cache_key, file_name FROM image_render_cache WHERE expires_at<=?", (now,), ) for row in rows: await self._delete_image_record( str(row["cache_key"]), self.image_dir / str(row["file_name"]), ) async def clear(self, cache_type: str) -> dict[str, int]: if cache_type not in {"api", "image", "all"}: raise ValueError("清理类型仅支持 api、image 或 all") removed = {"api": 0, "image": 0} if cache_type in {"api", "all"}: row = await self._sqlite.fetch_one( "SELECT COUNT(*) AS count FROM api_response_cache" ) removed["api"] = int((row or {}).get("count") or 0) await self._sqlite.execute("DELETE FROM api_response_cache") self._memory.clear() if cache_type in {"image", "all"}: rows = await self._sqlite.fetch_all( "SELECT cache_key, file_name FROM image_render_cache" ) removed["image"] = len(rows) for row in rows: await self._delete_image_record( str(row["cache_key"]), self.image_dir / str(row["file_name"]), ) return removed async def clear_item(self, cache_type: str, cache_name: str) -> int: if cache_type not in {"api", "image"}: raise ValueError("缓存类型仅支持 api 或 image") cache_name = str(cache_name or "").strip() if not cache_name: raise ValueError("缓存项目不能为空") if cache_type == "api": rows = await self._sqlite.fetch_all( "SELECT cache_key FROM api_response_cache WHERE endpoint=?", (cache_name,), ) await self._sqlite.delete( "api_response_cache", "endpoint=?", (cache_name,), ) for row in rows: self._memory.pop(str(row["cache_key"]), None) return len(rows) rows = await self._sqlite.fetch_all( """ SELECT cache_key, file_name FROM image_render_cache WHERE cache_name=? """, (cache_name,), ) for row in rows: await self._delete_image_record( str(row["cache_key"]), self.image_dir / str(row["file_name"]), ) return len(rows) def _setting_item(self, cache_type: str, cache_name: str) -> dict[str, Any]: return { "name": cache_name, "ttl_seconds": self.get_ttl(cache_type, cache_name), "overridden": (cache_type, cache_name) in self._settings, "safe_default": ( ( (cache_type == "api" and cache_name in self._NO_CACHE_API_DEFAULTS) or ( cache_type == "image" and cache_name in {"避雷查看", "避雷查询"} ) ) and (cache_type, cache_name) not in self._settings ), } async def dashboard(self) -> dict[str, Any]: await self.cleanup_expired() api_row = await self._sqlite.fetch_one( """ SELECT COUNT(*) AS count, COALESCE(SUM(LENGTH(CAST(payload AS BLOB))), 0) AS size_bytes FROM api_response_cache """ ) image_row = await self._sqlite.fetch_one( """ SELECT COUNT(*) AS count, COALESCE(SUM(size_bytes), 0) AS size_bytes FROM image_render_cache """ ) known_api_names = set(API_ENDPOINTS) known_api_names.update( name for cache_type, name in self._settings if cache_type == "api" and name != "*" ) known_image_names = set(self._image_names) known_image_names.update( name for cache_type, name in self._settings if cache_type == "image" and name != "*" ) return { "defaults": { "api": self.get_ttl("api", "*"), "image": self.get_ttl("image", "*"), }, "limits": { "api_memory_entries": self.max_memory_entries, "image_max_mb": self.max_image_bytes // 1024 // 1024, }, "api": [ self._setting_item("api", name) for name in sorted(known_api_names) ], "images": [ self._setting_item("image", name) for name in sorted( known_image_names, key=lambda value: value.encode("utf-8") ) ], "stats": { "api_count": int((api_row or {}).get("count") or 0), "api_size_bytes": int((api_row or {}).get("size_bytes") or 0), "api_memory_count": len(self._memory), "api_memory_limit": self.max_memory_entries, "image_count": int((image_row or {}).get("count") or 0), "image_size_bytes": int((image_row or {}).get("size_bytes") or 0), "image_limit_bytes": self.max_image_bytes, }, }