mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-15 18:23:21 -03:00
c85b6b64a1
Track recipe modal opens in a separate stats file (never touching recipe JSON/EXIF), expose a fire-and-forget POST endpoint, and add an 'opened' sort that hides never-opened recipes as a true recently-opened view. Includes i18n for all locales and backend/frontend tests.
162 lines
6.1 KiB
Python
162 lines
6.1 KiB
Python
"""Track recipe modal open timestamps for the "Recently Opened" sort.
|
|
|
|
The data is deliberately kept OUTSIDE the recipe metadata files: recording an
|
|
open must be cheap and must never rewrite recipe JSON or EXIF (which the
|
|
generic metadata update path does). A tiny JSON map of
|
|
``recipe_id -> unix timestamp`` lives under
|
|
``{settings_dir}/stats/recipe_last_opened.json`` and is written atomically on
|
|
a short debounce.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
|
|
from ..utils.settings_paths import get_settings_dir
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RecipeOpenStats:
|
|
"""Persist the last time each recipe was opened in the recipe modal."""
|
|
|
|
STATS_FILENAME: str = "recipe_last_opened.json"
|
|
SAVE_DELAY: float = 1.0 # seconds of debounce between consecutive writes
|
|
|
|
_instance: "RecipeOpenStats | None" = None
|
|
_opened: dict[str, float]
|
|
_file_mtime: float | None
|
|
_dirty: bool
|
|
_lock: asyncio.Lock
|
|
_save_task: "asyncio.Task[None] | None"
|
|
_stats_file_path: str
|
|
_initialized: bool
|
|
|
|
def __new__(cls) -> "RecipeOpenStats":
|
|
if cls._instance is None:
|
|
cls._instance = super().__new__(cls)
|
|
cls._instance._initialized = False
|
|
return cls._instance
|
|
|
|
def __init__(self) -> None:
|
|
if getattr(self, "_initialized", False):
|
|
return
|
|
self._opened = {}
|
|
self._file_mtime = None
|
|
self._dirty = False
|
|
self._lock = asyncio.Lock()
|
|
self._save_task = None
|
|
self._stats_file_path = self._get_stats_file_path()
|
|
self._load_stats()
|
|
self._initialized = True
|
|
|
|
def _get_stats_file_path(self) -> str:
|
|
settings_dir = get_settings_dir(create=True)
|
|
return os.path.join(settings_dir, "stats", self.STATS_FILENAME)
|
|
|
|
def _load_stats(self) -> None:
|
|
"""Load the opened map from disk, tolerating corrupt/absent files.
|
|
|
|
The mtime is recorded even when parsing fails so a corrupt file is
|
|
not re-read (and re-logged) on every lookup.
|
|
"""
|
|
if not os.path.exists(self._stats_file_path):
|
|
return
|
|
try:
|
|
mtime = os.path.getmtime(self._stats_file_path)
|
|
except OSError:
|
|
return
|
|
try:
|
|
with open(self._stats_file_path, "r", encoding="utf-8") as file_obj:
|
|
raw = json.load(file_obj)
|
|
if isinstance(raw, dict):
|
|
self._opened = {
|
|
str(key): float(value)
|
|
for key, value in raw.items()
|
|
if isinstance(value, (int, float))
|
|
}
|
|
except Exception as exc: # pragma: no cover - defensive logging path
|
|
logger.error("Error loading recipe open stats: %s", exc)
|
|
self._opened = {}
|
|
self._file_mtime = mtime
|
|
|
|
def get_opened_map(self) -> dict[str, float]:
|
|
"""Return a copy of ``recipe_id -> last opened timestamp``.
|
|
|
|
Refreshes from disk when the file changed since the last load so a
|
|
second server process (or manual edit) is picked up without restart.
|
|
"""
|
|
try:
|
|
if os.path.exists(self._stats_file_path):
|
|
mtime = os.path.getmtime(self._stats_file_path)
|
|
if self._file_mtime is None or mtime != self._file_mtime:
|
|
self._load_stats()
|
|
except OSError:
|
|
pass
|
|
return dict(self._opened)
|
|
|
|
def record_open(self, recipe_id: str) -> None:
|
|
"""Mark a recipe as opened now; persists shortly in the background."""
|
|
if not recipe_id:
|
|
return
|
|
self._opened[str(recipe_id)] = time.time()
|
|
self._dirty = True
|
|
if self._save_task is None or self._save_task.done():
|
|
self._save_task = asyncio.create_task(self._delayed_save())
|
|
|
|
async def _delayed_save(self) -> None:
|
|
"""Debounced writer: batches rapid consecutive opens into one write."""
|
|
await asyncio.sleep(self.SAVE_DELAY)
|
|
_ = await self.save_stats()
|
|
|
|
async def save_stats(self, force: bool = False) -> bool:
|
|
"""Persist the opened map atomically if dirty (or when forced).
|
|
|
|
The on-disk map is merged in first so a second process sharing the
|
|
settings dir does not lose its entries; the larger timestamp wins
|
|
per recipe.
|
|
"""
|
|
if not force and not self._dirty:
|
|
return False
|
|
async with self._lock:
|
|
if not force and not self._dirty:
|
|
return False
|
|
try:
|
|
merged = self._merge_with_disk()
|
|
os.makedirs(os.path.dirname(self._stats_file_path), exist_ok=True)
|
|
temp_path = f"{self._stats_file_path}.tmp"
|
|
with open(temp_path, "w", encoding="utf-8") as file_obj:
|
|
json.dump(merged, file_obj, indent=2)
|
|
os.replace(temp_path, self._stats_file_path)
|
|
self._opened = merged
|
|
self._file_mtime = os.path.getmtime(self._stats_file_path)
|
|
self._dirty = False
|
|
return True
|
|
except Exception as exc: # pragma: no cover - defensive logging path
|
|
logger.error("Error saving recipe open stats: %s", exc, exc_info=True)
|
|
return False
|
|
|
|
def _merge_with_disk(self) -> dict[str, float]:
|
|
"""Merge the in-memory map with the current on-disk map."""
|
|
disk: dict[str, float] = {}
|
|
try:
|
|
if os.path.exists(self._stats_file_path):
|
|
with open(self._stats_file_path, "r", encoding="utf-8") as file_obj:
|
|
raw = json.load(file_obj)
|
|
if isinstance(raw, dict):
|
|
disk = {
|
|
str(key): float(value)
|
|
for key, value in raw.items()
|
|
if isinstance(value, (int, float))
|
|
}
|
|
except Exception as exc: # pragma: no cover - defensive logging path
|
|
logger.error("Error reading recipe open stats for merge: %s", exc)
|
|
merged = dict(disk)
|
|
for key, value in self._opened.items():
|
|
merged[key] = max(value, disk.get(key, 0.0))
|
|
return merged
|