mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-16 02:33:21 -03:00
feat(recipes): add recently opened sort with modal open tracking
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.
This commit is contained in:
@@ -34,6 +34,7 @@ from ...utils.civitai_utils import (
|
||||
)
|
||||
from ...utils.constants import NSFW_LEVELS
|
||||
from ...utils.exif_utils import ExifUtils
|
||||
from ...utils.recipe_open_stats import RecipeOpenStats
|
||||
from ...recipes.merger import GenParamsMerger
|
||||
from ...recipes.enrichment import RecipeEnricher
|
||||
from ...services.websocket_manager import ws_manager as default_ws_manager
|
||||
@@ -98,6 +99,7 @@ class RecipeHandlerSet:
|
||||
"download_shared_recipe": self.sharing.download_shared_recipe,
|
||||
"get_recipe_syntax": self.query.get_recipe_syntax,
|
||||
"update_recipe": self.management.update_recipe,
|
||||
"record_recipe_open": self.management.record_recipe_open,
|
||||
"reconnect_lora": self.management.reconnect_lora,
|
||||
"find_duplicates": self.query.find_duplicates,
|
||||
"move_recipes_bulk": self.management.move_recipes_bulk,
|
||||
@@ -1458,6 +1460,33 @@ class RecipeManagementHandler:
|
||||
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def record_recipe_open(self, request: web.Request) -> web.Response:
|
||||
"""Record that a recipe's detail modal was opened.
|
||||
|
||||
Lightweight fire-and-forget endpoint backing the "Recently Opened"
|
||||
sort. It only writes the timestamp into the separate open-stats file
|
||||
— recipe JSON and EXIF are never touched.
|
||||
"""
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
recipe_id = request.match_info["recipe_id"]
|
||||
# Skip recording opens for recipes the scanner no longer knows.
|
||||
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||
if not recipe_json_path:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Recipe not found"}, status=404
|
||||
)
|
||||
|
||||
RecipeOpenStats().record_open(recipe_id)
|
||||
return web.json_response({"success": True})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error recording recipe open: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def move_recipe(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
|
||||
@@ -43,6 +43,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
||||
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/{recipe_id}/opened", "record_recipe_open"
|
||||
),
|
||||
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Un
|
||||
from ..config import config
|
||||
from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES
|
||||
from ..utils.file_utils import calculate_autov3
|
||||
from ..utils.recipe_open_stats import RecipeOpenStats
|
||||
from .recipe_cache import RecipeCache
|
||||
from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
|
||||
from natsort import natsorted
|
||||
@@ -2782,9 +2783,11 @@ class RecipeScanner:
|
||||
Args:
|
||||
page: Current page number (1-based)
|
||||
page_size: Number of items per page
|
||||
sort_by: Sort method ('name', 'date', 'loras_count', or 'random'
|
||||
with an optional seed like 'random:abc123'; the part after
|
||||
'random:' is the shuffle seed, not a direction)
|
||||
sort_by: Sort method ('name', 'date', 'loras_count', 'opened',
|
||||
or 'random' with an optional seed like 'random:abc123'; the
|
||||
part after 'random:' is the shuffle seed, not a direction).
|
||||
'opened' hides recipes that were never opened — it is a
|
||||
"recently opened" view, not a plain reorder
|
||||
search: Search term
|
||||
filters: Dictionary of filters to apply
|
||||
search_options: Dictionary of search options to apply
|
||||
@@ -2965,7 +2968,7 @@ class RecipeScanner:
|
||||
]
|
||||
|
||||
# Apply sorting if not already handled by pre-sorted cache
|
||||
if ":" in sort_by or sort_field in ("loras_count", "random"):
|
||||
if ":" in sort_by or sort_field in ("loras_count", "random", "opened"):
|
||||
field, order = (sort_by.split(":") + ["desc"])[:2]
|
||||
reverse = order.lower() == "desc"
|
||||
|
||||
@@ -2984,6 +2987,20 @@ class RecipeScanner:
|
||||
),
|
||||
reverse=reverse,
|
||||
)
|
||||
elif field == "opened":
|
||||
# "Recently Opened" view: recipes never opened are hidden.
|
||||
# The open stats live outside recipe metadata; see
|
||||
# RecipeOpenStats.
|
||||
opened_map = RecipeOpenStats().get_opened_map()
|
||||
filtered_data = [
|
||||
item
|
||||
for item in filtered_data
|
||||
if opened_map.get(str(item.get("id", ""))) is not None
|
||||
]
|
||||
filtered_data.sort(
|
||||
key=lambda x: opened_map.get(str(x.get("id", "")), 0),
|
||||
reverse=reverse,
|
||||
)
|
||||
elif field == "loras_count":
|
||||
filtered_data.sort(
|
||||
key=lambda x: len(x.get("loras", [])), reverse=reverse
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user