Compare commits

..

9 Commits
v1.1.0 ... main

Author SHA1 Message Date
Will Miao
138024aefe fix(preview): revert to FileResponse as default for all platforms (#975)
The previous commit (a19ddc14) restored Linux sendfile but kept the
manual streaming path for Windows via sys.platform guard. A Windows
user reports performance is still worse than v1.0.5.

Switch back to web.FileResponse for all files on all platforms as the
default. The IOCP crash is an edge case (fast scrolling through many
video previews) that affects few users, while the Python chunked I/O
performance penalty affects everyone.

_stream_file() is kept as an unused fallback for a future compat
setting toggle.
2026-06-13 21:43:44 +08:00
Will Miao
a19ddc14f6 perf(preview): restore Linux sendfile, add cache headers, increase chunk size (#975)
- Restrict manual video streaming to Windows only (sys.platform == 'win32');
  Linux/macOS now uses kernel sendfile (zero-copy DMA) via aiohttp FileResponse
- Add Cache-Control: public, max-age=86400 to streaming responses so browsers
  cache video previews across scroll cycles
- Increase chunk size from 256KB to 1MB to reduce async iteration overhead on
  Windows where streaming is still required
2026-06-13 20:06:58 +08:00
Will Miao
7001ced694 fix(rate-limit): respect server retry_after instead of capping at 30s 2026-06-13 18:01:13 +08:00
pixelpaws
a5c861646c Merge pull request #974 from itkitteh/fix/socks-proxy-support
fix: support SOCKS proxies for outbound requests
2026-06-13 14:15:02 +08:00
Artem Yakimenko
3e0bb73793 fix: support SOCKS proxies for outbound requests
The proxy settings allow selecting a SOCKS proxy type, but the SOCKS
URL was passed to aiohttp's per-request `proxy=` argument, which only
supports http(s) proxies. With a SOCKS proxy this opens a plain TCP
connection to the proxy port and sends an HTTP request; the SOCKS
server replies with its handshake bytes (e.g. b"\x05\xff") and aiohttp
fails with "Bad status line ... Expected HTTP/, RTSP/ or ICE/".

Route SOCKS proxy types through an aiohttp-socks ProxyConnector on the
session instead, leaving the `proxy=` kwarg for http(s) proxies only.
trust_env now keys off whether an app-level proxy is active. Adds
aiohttp-socks to requirements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:05:15 +10:00
Will Miao
ac51f6a2f6 feat(settings): add adjustable card overlay blur setting (#973) 2026-06-13 09:43:49 +08:00
Will Miao
bef222c77d perf(recipe): precompute image_id_map for O(1) CivitAI image existence checks
Build a civitai_image_id → recipe_id mapping once during cache
initialization instead of scanning all recipes on every
check_image_exists and import_from_url call.

- RecipeCache gains an image_id_map field populated by
  _build_image_id_map() during cache init
- check_image_exists and import_from_url duplicate detection
  now use the precomputed map (O(k) / O(1) vs O(n))
- Map is persisted in SQLite cache_metadata for fast startup
- Incrementally updated on add/remove/bulk_remove paths
- Fix: conn.close() before cache_metadata query (dead connection)
2026-06-13 08:32:03 +08:00
Will Miao
7cd6a53447 fix(downloads): accept optional completed_at in complete_download to preserve original timestamps 2026-06-13 07:06:59 +08:00
willmiao
6850b35770 docs: auto-update supporters list in README 2026-06-12 15:38:33 +00:00
29 changed files with 619 additions and 63 deletions

File diff suppressed because one or more lines are too long

View File

@@ -448,7 +448,9 @@
"modelName": "Modellname", "modelName": "Modellname",
"fileName": "Dateiname" "fileName": "Dateiname"
}, },
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll" "modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll",
"cardBlurAmount": "Karten-Overlay-Unschärfe",
"cardBlurAmountHelp": "Passen Sie die Unschärfeintensität der Kopf- und Fußzeilen-Overlays auf Modell- und Rezeptkarten an (0 = keine Unschärfe, 20 = maximale Unschärfe)."
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "Aktive Bibliothek", "activeLibrary": "Aktive Bibliothek",

View File

@@ -448,7 +448,9 @@
"modelName": "Model Name", "modelName": "Model Name",
"fileName": "File Name" "fileName": "File Name"
}, },
"modelNameDisplayHelp": "Choose what to display in the model card footer" "modelNameDisplayHelp": "Choose what to display in the model card footer",
"cardBlurAmount": "Card Overlay Blur",
"cardBlurAmountHelp": "Adjust the blur intensity of the header and footer overlays on model and recipe cards (0 = no blur, 20 = maximum blur)."
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "Active Library", "activeLibrary": "Active Library",

View File

@@ -448,7 +448,9 @@
"modelName": "Nombre del modelo", "modelName": "Nombre del modelo",
"fileName": "Nombre del archivo" "fileName": "Nombre del archivo"
}, },
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo" "modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo",
"cardBlurAmount": "Desenfoque de superposición de tarjetas",
"cardBlurAmountHelp": "Ajuste la intensidad de desenfoque de las superposiciones del encabezado y pie de página en las tarjetas de modelos y recetas (0 = sin desenfoque, 20 = desenfoque máximo)."
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "Biblioteca activa", "activeLibrary": "Biblioteca activa",

View File

@@ -448,7 +448,9 @@
"modelName": "Nom du modèle", "modelName": "Nom du modèle",
"fileName": "Nom du fichier" "fileName": "Nom du fichier"
}, },
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle" "modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle",
"cardBlurAmount": "Flou de superposition des cartes",
"cardBlurAmountHelp": "Ajustez l'intensité du flou des superpositions d'en-tête et de pied de page sur les cartes de modèles et de recettes (0 = aucun flou, 20 = flou maximal)."
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "Bibliothèque active", "activeLibrary": "Bibliothèque active",

View File

@@ -448,7 +448,9 @@
"modelName": "שם מודל", "modelName": "שם מודל",
"fileName": "שם קובץ" "fileName": "שם קובץ"
}, },
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל" "modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל",
"cardBlurAmount": "עוצמת טשטוש שכבת-על בכרטיס",
"cardBlurAmountHelp": "כוונן את עוצמת הטשטוש של שכבת-העל בכותרת ובכותרות תחתונה בכרטיסי מודל ומתכונים (0 = ללא טשטוש, 20 = טשטוש מקסימלי)."
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "ספרייה פעילה", "activeLibrary": "ספרייה פעילה",

View File

@@ -448,7 +448,9 @@
"modelName": "モデル名", "modelName": "モデル名",
"fileName": "ファイル名" "fileName": "ファイル名"
}, },
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択" "modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択",
"cardBlurAmount": "カードオーバーレイのぼかし",
"cardBlurAmountHelp": "モデルカードとレシピカードのヘッダー・フッターオーバーレイのぼかし強度を調整します0 = ぼかしなし、20 = 最大ぼかし)。"
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "アクティブライブラリ", "activeLibrary": "アクティブライブラリ",

View File

@@ -448,7 +448,9 @@
"modelName": "모델명", "modelName": "모델명",
"fileName": "파일명" "fileName": "파일명"
}, },
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요" "modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요",
"cardBlurAmount": "카드 오버레이 흐림 강도",
"cardBlurAmountHelp": "모델 및 레시피 카드의 헤더와 푸터 오버레이 흐림 강도를 조정합니다 (0 = 흐림 없음, 20 = 최대 흐림)."
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "활성 라이브러리", "activeLibrary": "활성 라이브러리",

View File

@@ -448,7 +448,9 @@
"modelName": "Название модели", "modelName": "Название модели",
"fileName": "Имя файла" "fileName": "Имя файла"
}, },
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели" "modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели",
"cardBlurAmount": "Размытие наложения карточек",
"cardBlurAmountHelp": "Настройте интенсивность размытия наложений верхнего и нижнего колонтитулов на карточках моделей и рецептов (0 = без размытия, 20 = максимальное размытие)."
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "Активная библиотека", "activeLibrary": "Активная библиотека",

View File

@@ -448,7 +448,9 @@
"modelName": "模型名称", "modelName": "模型名称",
"fileName": "文件名" "fileName": "文件名"
}, },
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容" "modelNameDisplayHelp": "选择在模型卡片底部显示的内容",
"cardBlurAmount": "卡片叠加模糊强度",
"cardBlurAmountHelp": "调整模型和配方卡片上页眉和页脚叠加层的模糊强度0 = 无模糊20 = 最大模糊)。"
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "活动库", "activeLibrary": "活动库",

View File

@@ -448,7 +448,9 @@
"modelName": "模型名稱", "modelName": "模型名稱",
"fileName": "檔案名稱" "fileName": "檔案名稱"
}, },
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容" "modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容",
"cardBlurAmount": "卡片疊加模糊強度",
"cardBlurAmountHelp": "調整模型和配方卡片上頁首和頁尾疊加層的模糊強度0 = 無模糊20 = 最大模糊)。"
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "使用中的資料庫", "activeLibrary": "使用中的資料庫",

View File

@@ -1785,6 +1785,8 @@ class ModelDownloadHandler:
bytes_downloaded = 0 bytes_downloaded = 0
total_bytes_raw = request.query.get("total_bytes") total_bytes_raw = request.query.get("total_bytes")
total_bytes = int(total_bytes_raw) if total_bytes_raw else None total_bytes = int(total_bytes_raw) if total_bytes_raw else None
completed_at_raw = request.query.get("completed_at")
completed_at = float(completed_at_raw) if completed_at_raw else None
service = await DownloadQueueService.get_instance() service = await DownloadQueueService.get_instance()
item = await service.complete_download( item = await service.complete_download(
@@ -1794,6 +1796,7 @@ class ModelDownloadHandler:
file_path=file_path, file_path=file_path,
bytes_downloaded=bytes_downloaded, bytes_downloaded=bytes_downloaded,
total_bytes=total_bytes, total_bytes=total_bytes,
completed_at=completed_at,
) )
if item is None: if item is None:
return web.json_response( return web.json_response(

View File

@@ -13,7 +13,7 @@ from ...config import config as global_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_CHUNK_SIZE = 256 * 1024 # 256 KB _CHUNK_SIZE = 1024 * 1024 # 1 MB — balance between streaming iteration overhead and per-chunk memory
# Video file extensions that bypass native sendfile on Windows # Video file extensions that bypass native sendfile on Windows
# to avoid IOCP/ProactorEventLoop crashes during client disconnect. # to avoid IOCP/ProactorEventLoop crashes during client disconnect.
@@ -55,15 +55,11 @@ class PreviewHandler:
logger.debug("Preview file not found at %s", str(resolved)) logger.debug("Preview file not found at %s", str(resolved))
raise web.HTTPNotFound(text="Preview file not found") raise web.HTTPNotFound(text="Preview file not found")
# Video files: stream manually to avoid Windows native sendfile crash. # aiohttp's FileResponse handles range requests, content headers, and
# aiohttp's FileResponse uses _sendfile_native on Windows (IOCP-based), # uses kernel sendfile (zero-copy DMA) on Linux/macOS. On Windows it
# which breaks when the client disconnects mid-transfer — this happens # uses IOCP-based _sendfile_native which can crash when the client
# constantly when users scroll through a gallery of animated previews. # disconnects mid-transfer during fast scrolling. The _stream_file()
suffix = resolved.suffix.lower() # fallback is kept for a future compat toggle.
if suffix in _VIDEO_EXTENSIONS:
return await self._stream_file(request, resolved)
# aiohttp's FileResponse handles range requests and content headers for us.
return web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE) return web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
async def _stream_file( async def _stream_file(
@@ -83,6 +79,10 @@ class PreviewHandler:
resp.content_type = content_type resp.content_type = content_type
resp.content_length = file_size resp.content_length = file_size
# Allow browser caching: video previews rarely change during a session.
# The frontend already appends ?t={version} to bust cache on update.
resp.headers["Cache-Control"] = "public, max-age=86400"
await resp.prepare(request) await resp.prepare(request)
try: try:

View File

@@ -1597,15 +1597,8 @@ class RecipeManagementHandler:
cache = await recipe_scanner.get_cached_data() cache = await recipe_scanner.get_cached_data()
# Build lookup: image_id -> recipe_id from stored source_path # Use precomputed image_id_map (built once at cache init)
image_to_recipe = {} image_to_recipe = getattr(cache, "image_id_map", {})
for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path")
if not source:
continue
image_id = extract_civitai_image_id(source)
if image_id and image_id not in image_to_recipe:
image_to_recipe[image_id] = recipe.get("id")
results = {} results = {}
for img_id in requested_ids: for img_id in requested_ids:
@@ -1641,18 +1634,20 @@ class RecipeManagementHandler:
"Could not extract Civitai image ID from URL" "Could not extract Civitai image ID from URL"
) )
# Check for duplicate (fast, before acquiring semaphore), unless force
if not force: if not force:
cache = await recipe_scanner.get_cached_data() cache = await recipe_scanner.get_cached_data()
image_to_recipe = getattr(cache, "image_id_map", {})
existing_recipe_id = image_to_recipe.get(image_id)
if existing_recipe_id:
recipe_name = ""
for recipe in getattr(cache, "raw_data", []): for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path") if str(recipe.get("id", "")) == existing_recipe_id:
if source: recipe_name = recipe.get("title", "") or ""
existing_id = extract_civitai_image_id(source) break
if existing_id == image_id:
return web.json_response({ return web.json_response({
"success": True, "success": True,
"recipe_id": recipe.get("id"), "recipe_id": existing_recipe_id,
"name": recipe.get("title", ""), "name": recipe_name,
"already_exists": True, "already_exists": True,
}) })

View File

@@ -349,6 +349,7 @@ class DownloadQueueService:
file_path: Optional[str] = None, file_path: Optional[str] = None,
bytes_downloaded: int = 0, bytes_downloaded: int = 0,
total_bytes: Optional[int] = None, total_bytes: Optional[int] = None,
completed_at: Optional[float] = None,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
"""Atomically move a download from the queue into the history table. """Atomically move a download from the queue into the history table.
@@ -356,6 +357,9 @@ class DownloadQueueService:
queue, and inserts a corresponding history entry with the given queue, and inserts a corresponding history entry with the given
terminal status (``completed``, ``failed``, or ``canceled``). terminal status (``completed``, ``failed``, or ``canceled``).
When *completed_at* is provided it is used as the completion
timestamp; otherwise ``time.time()`` is used.
Returns the original queue record (before deletion) on success, Returns the original queue record (before deletion) on success,
or ``None`` if the download was not found in the queue. or ``None`` if the download was not found in the queue.
""" """
@@ -368,7 +372,7 @@ class DownloadQueueService:
if row is None: if row is None:
return None return None
now = time.time() now = completed_at if completed_at is not None else time.time()
conn.execute( conn.execute(
"DELETE FROM download_queue WHERE download_id = ?", "DELETE FROM download_queue WHERE download_id = ?",
(download_id,), (download_id,),

View File

@@ -256,7 +256,9 @@ class Downloader:
self._session = None self._session = None
# Check for app-level proxy settings # Check for app-level proxy settings
proxy_url = None proxy_url = None # http(s) proxy, passed via the per-request `proxy=` kwarg
socks_proxy_url = None # SOCKS proxy, handled via aiohttp-socks connector
app_proxy_active = False
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
if settings_manager.get("proxy_enabled", False): if settings_manager.get("proxy_enabled", False):
proxy_host = settings_manager.get("proxy_host", "").strip() proxy_host = settings_manager.get("proxy_host", "").strip()
@@ -268,9 +270,19 @@ class Downloader:
if proxy_host and proxy_port: if proxy_host and proxy_port:
# Build proxy URL # Build proxy URL
if proxy_username and proxy_password: if proxy_username and proxy_password:
proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}" full_proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
else: else:
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}" full_proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
app_proxy_active = True
# aiohttp cannot tunnel SOCKS via the per-request `proxy=` kwarg
# (it would send HTTP to the SOCKS port and fail parsing the
# SOCKS handshake reply). SOCKS must be handled by an
# aiohttp-socks ProxyConnector instead.
if proxy_type.startswith("socks"):
socks_proxy_url = full_proxy_url
else:
proxy_url = full_proxy_url
logger.debug( logger.debug(
f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}" f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}"
@@ -294,13 +306,27 @@ class Downloader:
logger.debug("SSL: certifi unavailable; using system default CA bundle") logger.debug("SSL: certifi unavailable; using system default CA bundle")
# Optimize TCP connection parameters # Optimize TCP connection parameters
connector = aiohttp.TCPConnector( connector_kwargs = dict(
ssl=ssl_context, ssl=ssl_context,
limit=8, # Concurrent connections limit=8, # Concurrent connections
ttl_dns_cache=300, # DNS cache timeout ttl_dns_cache=300, # DNS cache timeout
force_close=False, # Keep connections for reuse force_close=False, # Keep connections for reuse
enable_cleanup_closed=True, enable_cleanup_closed=True,
) )
if socks_proxy_url:
# Route all traffic through the SOCKS proxy via aiohttp-socks. The
# connector tunnels every connection, so no per-request `proxy=` is
# used (and must not be — see self._proxy_url below).
try:
from aiohttp_socks import ProxyConnector
except ImportError as e: # pragma: no cover
raise RuntimeError(
"A SOCKS proxy is configured but the 'aiohttp-socks' package "
"is not installed. Install it with: pip install aiohttp-socks"
) from e
connector = ProxyConnector.from_url(socks_proxy_url, **connector_kwargs)
else:
connector = aiohttp.TCPConnector(**connector_kwargs)
# Configure timeout parameters # Configure timeout parameters
timeout = aiohttp.ClientTimeout( timeout = aiohttp.ClientTimeout(
@@ -311,12 +337,14 @@ class Downloader:
self._session = aiohttp.ClientSession( self._session = aiohttp.ClientSession(
connector=connector, connector=connector,
trust_env=proxy_url # Only fall back to system/env proxy when no app-level proxy is active
is None, # Only use system proxy if no app-level proxy is set trust_env=not app_proxy_active,
timeout=timeout, timeout=timeout,
) )
# Store proxy URL for use in requests # Store proxy URL for per-request use. Stays None for SOCKS because the
# ProxyConnector already tunnels everything; passing proxy= for SOCKS
# would re-trigger the original aiohttp parse error.
self._proxy_url = proxy_url self._proxy_url = proxy_url
self._session_created_at = datetime.now() self._session_created_at = datetime.now()

View File

@@ -81,7 +81,11 @@ class _RateLimitRetryHelper:
def _calculate_delay(self, retry_after: Optional[float], attempt: int) -> float: def _calculate_delay(self, retry_after: Optional[float], attempt: int) -> float:
if retry_after is not None: if retry_after is not None:
return min(self._max_delay, max(0.0, retry_after)) # Cap at 1800s (30 min) as a safety ceiling. The old 30s cap was
# too low — CivArchive can return retry_after ~1500s, causing all
# retries to fail. A generous ceiling protects against pathological
# server values while still respecting the server's guidance.
return min(1800.0, max(0.0, retry_after))
base_delay = self._base_delay * (2 ** max(0, attempt - 1)) base_delay = self._base_delay * (2 ** max(0, attempt - 1))
jitter_span = base_delay * self._jitter_ratio jitter_span = base_delay * self._jitter_ratio

View File

@@ -12,7 +12,7 @@ import logging
import os import os
import sqlite3 import sqlite3
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple from typing import Dict, List, Optional, Set, Tuple
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
@@ -26,6 +26,8 @@ class PersistedRecipeData:
raw_data: List[Dict] raw_data: List[Dict]
file_stats: Dict[str, Tuple[float, int]] # json_path -> (mtime, size) file_stats: Dict[str, Tuple[float, int]] # json_path -> (mtime, size)
image_id_map: Dict[str, str] = field(default_factory=dict)
"""Precomputed mapping of civitai image_id → recipe_id."""
class PersistentRecipeCache: class PersistentRecipeCache:
@@ -116,6 +118,20 @@ class PersistentRecipeCache:
if not rows: if not rows:
return None return None
# Restore precomputed image_id_map if available
image_id_map: Dict[str, str] = {}
try:
meta_row = conn.execute(
"SELECT value FROM cache_metadata WHERE key = ?",
("image_id_map",),
).fetchone()
if meta_row:
parsed = json.loads(meta_row["value"])
if isinstance(parsed, dict):
image_id_map = parsed
except Exception:
pass # missing or corrupt — rebuilt on next cache refresh
finally: finally:
conn.close() conn.close()
except FileNotFoundError: except FileNotFoundError:
@@ -138,14 +154,24 @@ class PersistentRecipeCache:
row["file_size"] or 0, row["file_size"] or 0,
) )
return PersistedRecipeData(raw_data=raw_data, file_stats=file_stats) return PersistedRecipeData(
raw_data=raw_data,
file_stats=file_stats,
image_id_map=image_id_map,
)
def save_cache(self, recipes: List[Dict], json_paths: Optional[Dict[str, str]] = None) -> None: def save_cache(
self,
recipes: List[Dict],
json_paths: Optional[Dict[str, str]] = None,
image_id_map: Optional[Dict[str, str]] = None,
) -> None:
"""Save all recipes to SQLite cache. """Save all recipes to SQLite cache.
Args: Args:
recipes: List of recipe dictionaries to persist. recipes: List of recipe dictionaries to persist.
json_paths: Optional mapping of recipe_id -> json_path for file stats. json_paths: Optional mapping of recipe_id -> json_path for file stats.
image_id_map: Optional precomputed civitai image_id → recipe_id mapping.
""" """
if not self.is_enabled(): if not self.is_enabled():
return return
@@ -186,6 +212,12 @@ class PersistentRecipeCache:
recipe_rows, recipe_rows,
) )
# Persist image_id_map for O(1) lookups on cache load
conn.execute(
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
("image_id_map", json.dumps(image_id_map or {})),
)
conn.commit() conn.commit()
logger.debug("Persisted %d recipes to cache", len(recipe_rows)) logger.debug("Persisted %d recipes to cache", len(recipe_rows))
finally: finally:
@@ -273,6 +305,29 @@ class PersistentRecipeCache:
except Exception as exc: except Exception as exc:
logger.debug("Failed to remove recipe %s from cache: %s", recipe_id, exc) logger.debug("Failed to remove recipe %s from cache: %s", recipe_id, exc)
def save_image_id_map(self, image_id_map: Dict[str, str]) -> None:
"""Persist the image_id_map to cache_metadata without rewriting the full cache.
This is called after ``add_recipe`` / ``remove_recipe`` mutations so
the persistent copy does not go stale between full ``save_cache`` calls.
"""
if not self.is_enabled() or not self._schema_initialized:
return
try:
with self._db_lock:
conn = self._connect()
try:
conn.execute(
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
("image_id_map", json.dumps(image_id_map)),
)
conn.commit()
finally:
conn.close()
except Exception as exc:
logger.debug("Failed to persist image_id_map: %s", exc)
def get_indexed_recipe_ids(self) -> Set[str]: def get_indexed_recipe_ids(self) -> Set[str]:
"""Return all recipe IDs in the cache. """Return all recipe IDs in the cache.

View File

@@ -1,6 +1,6 @@
import asyncio import asyncio
from typing import Iterable, List, Dict, Optional from typing import Iterable, List, Dict, Optional
from dataclasses import dataclass from dataclasses import dataclass, field
from operator import itemgetter from operator import itemgetter
from natsort import natsorted from natsort import natsorted
@@ -14,6 +14,15 @@ class RecipeCache:
sorted_by_date: List[Dict] sorted_by_date: List[Dict]
folders: List[str] | None = None folders: List[str] | None = None
folder_tree: Dict | None = None folder_tree: Dict | None = None
image_id_map: Dict[str, str] = field(default_factory=dict)
"""Mapping of civitai image_id → recipe_id, precomputed at cache build time.
Built once during cache initialization (O(n)) so that
``check_image_exists`` and ``import_from_url`` duplicate checks
can look up image_id in O(1) instead of scanning all recipes.
Recipes imported from local files have no valid civitai image_id
and are naturally excluded from this map.
"""
def __post_init__(self): def __post_init__(self):
self._lock = asyncio.Lock() self._lock = asyncio.Lock()

View File

@@ -20,6 +20,7 @@ from .metadata_service import get_default_metadata_provider
from .checkpoint_scanner import CheckpointScanner from .checkpoint_scanner import CheckpointScanner
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from .recipes.errors import RecipeNotFoundError from .recipes.errors import RecipeNotFoundError
from ..utils.civitai_utils import extract_civitai_image_id
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
from natsort import natsorted from natsort import natsorted
import sys import sys
@@ -532,7 +533,21 @@ class RecipeScanner:
self._sort_cache_sync() self._sort_cache_sync()
# Backfill source_path from JSON files if missing (schema migration) # Backfill source_path from JSON files if missing (schema migration)
if self._backfill_source_path_if_needed(recipes, json_paths): if self._backfill_source_path_if_needed(recipes, json_paths):
self._persistent_cache.save_cache(recipes, json_paths) self._cache.image_id_map = self._build_image_id_map()
self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map
)
else:
# Use persisted map, or rebuild if empty (e.g. first startup
# after deploying the image_id_map feature).
if persisted.image_id_map:
self._cache.image_id_map = dict(persisted.image_id_map)
else:
self._cache.image_id_map = self._build_image_id_map()
if self._cache.image_id_map:
self._persistent_cache.save_image_id_map(
self._cache.image_id_map
)
return self._cache return self._cache
else: else:
# Partial update: some files changed # Partial update: some files changed
@@ -545,8 +560,11 @@ class RecipeScanner:
self._sort_cache_sync() self._sort_cache_sync()
# Backfill source_path from JSON files if missing (schema migration) # Backfill source_path from JSON files if missing (schema migration)
self._backfill_source_path_if_needed(recipes, json_paths) self._backfill_source_path_if_needed(recipes, json_paths)
self._cache.image_id_map = self._build_image_id_map()
# Persist updated cache # Persist updated cache
self._persistent_cache.save_cache(recipes, json_paths) self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map
)
return self._cache return self._cache
# Fall back to full directory scan # Fall back to full directory scan
@@ -558,9 +576,12 @@ class RecipeScanner:
self._cache.raw_data = recipes self._cache.raw_data = recipes
self._update_folder_metadata(self._cache) self._update_folder_metadata(self._cache)
self._sort_cache_sync() self._sort_cache_sync()
self._cache.image_id_map = self._build_image_id_map()
# Persist for next startup # Persist for next startup
self._persistent_cache.save_cache(recipes, json_paths) self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map
)
return self._cache return self._cache
except Exception as e: except Exception as e:
@@ -832,6 +853,28 @@ class RecipeScanner:
except Exception as e: except Exception as e:
logger.error(f"Error sorting recipe cache: {e}") logger.error(f"Error sorting recipe cache: {e}")
def _build_image_id_map(self) -> Dict[str, str]:
"""Build civitai image_id → recipe_id mapping from cached recipes.
Only recipes with a valid CivitAI image URL source_path produce an
entry. Recipes imported from local files are naturally excluded.
"""
mapping: Dict[str, str] = {}
if not self._cache:
return mapping
for recipe in getattr(self._cache, "raw_data", []):
if not isinstance(recipe, dict):
continue
source = recipe.get("source_path")
if not source:
continue
image_id = extract_civitai_image_id(source)
if image_id and image_id not in mapping:
recipe_id = recipe.get("id")
if recipe_id is not None:
mapping[image_id] = str(recipe_id)
return mapping
async def _wait_for_lora_scanner(self) -> None: async def _wait_for_lora_scanner(self) -> None:
"""Ensure the LoRA scanner has initialized before recipe enrichment.""" """Ensure the LoRA scanner has initialized before recipe enrichment."""
@@ -1296,11 +1339,20 @@ class RecipeScanner:
# Update FTS index # Update FTS index
self._update_fts_index_for_recipe(recipe_data, "add") self._update_fts_index_for_recipe(recipe_data, "add")
source = recipe_data.get("source_path")
if source:
image_id = extract_civitai_image_id(source)
if image_id:
recipe_id_value = recipe_data.get("id")
if recipe_id_value is not None:
cache.image_id_map[image_id] = str(recipe_id_value)
# Persist to SQLite cache # Persist to SQLite cache
if self._persistent_cache: if self._persistent_cache:
recipe_id = str(recipe_data.get("id", "")) recipe_id = str(recipe_data.get("id", ""))
json_path = self._json_path_map.get(recipe_id, "") json_path = self._json_path_map.get(recipe_id, "")
self._persistent_cache.update_recipe(recipe_data, json_path) self._persistent_cache.update_recipe(recipe_data, json_path)
self._persistent_cache.save_image_id_map(cache.image_id_map)
async def remove_recipe(self, recipe_id: str) -> bool: async def remove_recipe(self, recipe_id: str) -> bool:
"""Remove a recipe from the cache by ID.""" """Remove a recipe from the cache by ID."""
@@ -1319,9 +1371,15 @@ class RecipeScanner:
# Update FTS index # Update FTS index
self._update_fts_index_for_recipe(recipe_id, "remove") self._update_fts_index_for_recipe(recipe_id, "remove")
# Remove any image_id entry pointing to this recipe
stale = [k for k, v in cache.image_id_map.items() if v == recipe_id]
for k in stale:
del cache.image_id_map[k]
# Remove from SQLite cache # Remove from SQLite cache
if self._persistent_cache: if self._persistent_cache:
self._persistent_cache.remove_recipe(recipe_id) self._persistent_cache.remove_recipe(recipe_id)
self._persistent_cache.save_image_id_map(cache.image_id_map)
self._json_path_map.pop(recipe_id, None) self._json_path_map.pop(recipe_id, None)
return True return True
@@ -1332,14 +1390,21 @@ class RecipeScanner:
cache = await self.get_cached_data() cache = await self.get_cached_data()
removed = await cache.bulk_remove(recipe_ids, resort=False) removed = await cache.bulk_remove(recipe_ids, resort=False)
if removed: if removed:
removed_ids = {str(r.get("id", "")) for r in removed}
stale = [k for k, v in cache.image_id_map.items() if v in removed_ids]
for k in stale:
del cache.image_id_map[k]
self._schedule_resort() self._schedule_resort()
# Update FTS index and persistent cache for each removed recipe
for recipe in removed: for recipe in removed:
recipe_id = str(recipe.get("id", "")) recipe_id = str(recipe.get("id", ""))
self._update_fts_index_for_recipe(recipe_id, "remove") self._update_fts_index_for_recipe(recipe_id, "remove")
if self._persistent_cache: if self._persistent_cache:
self._persistent_cache.remove_recipe(recipe_id) self._persistent_cache.remove_recipe(recipe_id)
self._json_path_map.pop(recipe_id, None) self._json_path_map.pop(recipe_id, None)
if self._persistent_cache:
self._persistent_cache.save_image_id_map(cache.image_id_map)
return len(removed) return len(removed)
async def scan_all_recipes(self) -> List[Dict]: async def scan_all_recipes(self) -> List[Dict]:

View File

@@ -1,4 +1,5 @@
aiohttp aiohttp
aiohttp-socks
jinja2 jinja2
safetensors safetensors
piexif piexif

View File

@@ -278,7 +278,7 @@
left: 0; left: 0;
right: 0; right: 0;
background: linear-gradient(transparent 15%, oklch(0% 0 0 / 0.75)); background: linear-gradient(transparent 15%, oklch(0% 0 0 / 0.75));
backdrop-filter: blur(8px); backdrop-filter: blur(var(--card-blur-amount, 8px));
color: white; color: white;
padding: var(--space-1); padding: var(--space-1);
display: flex; display: flex;
@@ -294,7 +294,7 @@
left: 0; left: 0;
right: 0; right: 0;
background: linear-gradient(oklch(0% 0 0 / 0.75), transparent 85%); background: linear-gradient(oklch(0% 0 0 / 0.75), transparent 85%);
backdrop-filter: blur(8px); backdrop-filter: blur(var(--card-blur-amount, 8px));
color: white; color: white;
padding: var(--space-1); padding: var(--space-1);
display: flex; display: flex;

View File

@@ -813,6 +813,67 @@
outline: none; outline: none;
} }
/* Range Slider Control */
.range-control {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
justify-content: flex-end;
}
.range-control input[type="range"] {
width: 120px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: var(--border-color);
border-radius: 2px;
outline: none;
cursor: pointer;
flex-shrink: 0;
}
.range-control input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--lora-accent);
cursor: pointer;
border: 2px solid var(--lora-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.15s ease;
}
.range-control input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.range-control input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--lora-accent);
cursor: pointer;
border: 2px solid var(--lora-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.range-control .range-value {
min-width: 36px;
text-align: center;
font-size: 0.9em;
font-weight: 600;
color: var(--text-color);
font-variant-numeric: tabular-nums;
}
[data-theme="dark"] .range-control input[type="range"] {
background: rgba(255, 255, 255, 0.15);
}
/* Toggle Switch */ /* Toggle Switch */
.toggle-switch { .toggle-switch {
position: relative; position: relative;

View File

@@ -804,6 +804,16 @@ export class SettingsManager {
); );
} }
// Set card blur amount slider
const cardBlurAmountInput = document.getElementById('cardBlurAmount');
if (cardBlurAmountInput) {
cardBlurAmountInput.value = state.global.settings.card_blur_amount ?? 8;
}
const cardBlurAmountValue = document.getElementById('cardBlurAmountValue');
if (cardBlurAmountValue) {
cardBlurAmountValue.textContent = `${state.global.settings.card_blur_amount ?? 8}px`;
}
const usePortableCheckbox = document.getElementById('usePortableSettings'); const usePortableCheckbox = document.getElementById('usePortableSettings');
if (usePortableCheckbox) { if (usePortableCheckbox) {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings; usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
@@ -2051,6 +2061,28 @@ export class SettingsManager {
} }
} }
async saveRangeSetting(elementId, displayId, settingKey) {
const element = document.getElementById(elementId);
if (!element) return;
const value = parseInt(element.value, 10);
try {
await this.saveSetting(settingKey, value);
this.applyFrontendSettings();
// Update the displayed value next to the slider
const displayEl = document.getElementById(displayId);
if (displayEl) {
displayEl.textContent = `${value}px`;
}
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
} catch (error) {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
updateExampleImagesOpenSettingsVisibility() { updateExampleImagesOpenSettingsVisibility() {
const openMode = state.global.settings.example_images_open_mode || 'system'; const openMode = state.global.settings.example_images_open_mode || 'system';
const localRootSetting = document.getElementById('exampleImagesLocalRootSetting'); const localRootSetting = document.getElementById('exampleImagesLocalRootSetting');
@@ -2887,6 +2919,10 @@ export class SettingsManager {
} }
applyFrontendSettings() { applyFrontendSettings() {
// Apply card blur amount to CSS custom property
const cardBlurAmount = state.global.settings.card_blur_amount ?? 8;
document.documentElement.style.setProperty('--card-blur-amount', `${cardBlurAmount}px`);
// Apply autoplay setting to existing videos in card previews // Apply autoplay setting to existing videos in card previews
const autoplayOnHover = state.global.settings.autoplay_on_hover; const autoplayOnHover = state.global.settings.autoplay_on_hover;
document.querySelectorAll('.card-preview video').forEach(video => { document.querySelectorAll('.card-preview video').forEach(video => {

View File

@@ -32,6 +32,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
auto_download_example_images: false, auto_download_example_images: false,
blur_mature_content: true, blur_mature_content: true,
mature_blur_level: 'R', mature_blur_level: 'R',
card_blur_amount: 8,
autoplay_on_hover: false, autoplay_on_hover: false,
display_density: 'default', display_density: 'default',
card_info_display: 'always', card_info_display: 'always',

View File

@@ -448,6 +448,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Video Settings --> <!-- Video Settings -->
@@ -556,6 +557,23 @@
</div> </div>
</div> </div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="cardBlurAmount">
{{ t('settings.layoutSettings.cardBlurAmount') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.cardBlurAmountHelp') }}"></i>
</label>
</div>
<div class="setting-control range-control">
<input type="range" id="cardBlurAmount" min="0" max="20" value="8" step="1"
oninput="document.getElementById('cardBlurAmountValue').textContent = this.value + 'px'"
onchange="settingsManager.saveRangeSetting('cardBlurAmount', 'cardBlurAmountValue', 'card_blur_amount')">
<span id="cardBlurAmountValue" class="range-value">8px</span>
</div>
</div>
</div>
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">

View File

@@ -46,6 +46,7 @@ class StubRecipeScanner:
self.last_paginated_params: Dict[str, Any] | None = None self.last_paginated_params: Dict[str, Any] | None = None
self.lora_lookup: Dict[str, List[Dict[str, Any]]] = {} self.lora_lookup: Dict[str, List[Dict[str, Any]]] = {}
self.checkpoint_lookup: Dict[str, List[Dict[str, Any]]] = {} self.checkpoint_lookup: Dict[str, List[Dict[str, Any]]] = {}
self.image_id_map_override: Dict[str, str] = {}
async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner
return None return None
@@ -56,7 +57,10 @@ class StubRecipeScanner:
) )
async def get_cached_data(self, force_refresh: bool = False) -> SimpleNamespace: # noqa: ARG002 - flag unused by stub async def get_cached_data(self, force_refresh: bool = False) -> SimpleNamespace: # noqa: ARG002 - flag unused by stub
return SimpleNamespace(raw_data=list(self.cached_raw)) return SimpleNamespace(
raw_data=list(self.cached_raw),
image_id_map=dict(getattr(self, "image_id_map_override", {})),
)
async def get_paginated_data(self, **params: Any) -> Dict[str, Any]: async def get_paginated_data(self, **params: Any) -> Dict[str, Any]:
self.last_paginated_params = params self.last_paginated_params = params
@@ -999,3 +1003,95 @@ async def test_batch_import_cancel_missing_id(monkeypatch, tmp_path: Path) -> No
payload = await response.json() payload = await response.json()
assert response.status == 400 assert response.status == 400
assert payload["success"] is False assert payload["success"] is False
async def test_check_image_exists_uses_image_id_map(monkeypatch, tmp_path: Path) -> None:
"""check_image_exists must use precomputed image_id_map instead of scanning raw_data."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.image_id_map_override = {
"123": "recipe-alpha",
"789": "recipe-gamma",
}
response = await harness.client.get(
"/api/lm/recipes/check-image-exists",
params={"image_ids": "123,456,789"},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["results"]["123"] == {
"in_library": True,
"recipe_id": "recipe-alpha",
}
assert payload["results"]["456"] == {
"in_library": False,
"recipe_id": None,
}
assert payload["results"]["789"] == {
"in_library": True,
"recipe_id": "recipe-gamma",
}
async def test_check_image_exists_handles_empty_input(monkeypatch, tmp_path: Path) -> None:
"""Empty or non-numeric image_ids must return an empty results dict."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.get(
"/api/lm/recipes/check-image-exists",
params={"image_ids": ""},
)
payload = await response.json()
assert response.status == 200
assert payload["results"] == {}
async def test_import_from_url_detects_duplicate_via_image_id_map(
monkeypatch, tmp_path: Path,
) -> None:
"""import_from_url must return already_exists when image_id is in image_id_map."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.cached_raw = [
{"id": "existing-recipe", "title": "My Recipe"},
]
harness.scanner.image_id_map_override = {
"99999": "existing-recipe",
}
response = await harness.client.get(
"/api/lm/recipes/import-from-url",
params={"image_url": "https://civitai.com/images/99999"},
)
payload = await response.json()
assert response.status == 200
assert payload["already_exists"] is True
assert payload["recipe_id"] == "existing-recipe"
assert payload["name"] == "My Recipe"
async def test_import_from_url_proceeds_when_image_id_not_in_map(
monkeypatch, tmp_path: Path,
) -> None:
"""When image_id is absent from image_id_map, import_from_url must proceed to import."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.image_id_map_override = {
"111": "some-other-recipe",
}
harness.civitai.image_info["99999"] = {
"id": 99999,
"url": "https://image.civitai.com/x/y/original=true/sample.jpeg",
"type": "image",
"meta": {"prompt": "test"},
}
response = await harness.client.get(
"/api/lm/recipes/import-from-url",
params={"image_url": "https://civitai.com/images/99999"},
)
# The import may succeed or fail depending on downstream stubs,
# but it must NOT return already_exists
payload = await response.json()
assert payload.get("already_exists") is not True

View File

@@ -1015,3 +1015,85 @@ async def test_get_paginated_data_sorting(recipe_scanner):
# Test Date ASC: Gamma (5), Alpha (10), Beta (20) # Test Date ASC: Gamma (5), Alpha (10), Beta (20)
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="date:asc") res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="date:asc")
assert [i["id"] for i in res["items"]] == ["C", "A", "B"] assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
async def test_build_image_id_map_filters_correctly(recipe_scanner):
"""Only recipes with valid CivitAI source_path appear in image_id_map.
Recipes imported from local files or with empty/missing source_path
must be naturally excluded.
"""
scanner, _ = recipe_scanner
from py.services.recipe_cache import RecipeCache
scanner._cache = RecipeCache(
raw_data=[
{"id": "r1", "source_path": "https://civitai.com/images/12345"},
{"id": "r2", "source_path": "https://civitai.com/images/67890"},
{"id": "r3", "source_path": "/home/user/local_image.png"},
{"id": "r4", "source_path": ""},
{"id": "r5"},
],
sorted_by_name=[],
sorted_by_date=[],
)
result = scanner._build_image_id_map()
assert result == {
"12345": "r1",
"67890": "r2",
}
# r3 = local file path, r4 = empty string, r5 = no key → all excluded
for rid in ("r3", "r4", "r5"):
assert rid not in result.values()
async def test_add_recipe_updates_image_id_map(recipe_scanner):
"""Adding a recipe with a CivitAI URL must update image_id_map.
A recipe with a local file path must NOT produce an entry.
"""
scanner, _ = recipe_scanner
await scanner.add_recipe({
"id": "civitai-recipe",
"title": "CivitAI",
"source_path": "https://civitai.com/images/55555",
})
cache = await scanner.get_cached_data()
assert cache.image_id_map.get("55555") == "civitai-recipe"
await scanner.add_recipe({
"id": "local-recipe",
"title": "Local",
"source_path": "/path/to/local.png",
})
assert "local-recipe" not in cache.image_id_map.values()
async def test_remove_recipe_clears_image_id_map(recipe_scanner):
"""Removing a recipe that has a CivitAI image_id must clean up the map."""
scanner, _ = recipe_scanner
await scanner.add_recipe({
"id": "recipe-a",
"title": "A",
"source_path": "https://civitai.com/images/111",
})
await scanner.add_recipe({
"id": "recipe-b",
"title": "B",
"source_path": "https://civitai.com/images/222",
})
cache = await scanner.get_cached_data()
assert "111" in cache.image_id_map
assert cache.image_id_map["222"] == "recipe-b"
await scanner.remove_recipe("recipe-a")
assert "111" not in cache.image_id_map
assert cache.image_id_map["222"] == "recipe-b"

View File

@@ -465,3 +465,81 @@ class TestPersistentRecipeCache:
# Operations should complete # Operations should complete
assert operation_counts["saves"] == 5 assert operation_counts["saves"] == 5
assert operation_counts["removes"] == 5 assert operation_counts["removes"] == 5
# -----------------------------------------------------------------------
# image_id_map persistence (Phase 1 improvement)
# -----------------------------------------------------------------------
def test_save_and_load_image_id_map_roundtrip(self, temp_db_path, sample_recipes):
"""Save image_id_map via save_cache() and verify it round-trips through load_cache()."""
cache = PersistentRecipeCache(db_path=temp_db_path)
image_id_map = {
"12345": "recipe-alpha",
"67890": "recipe-beta",
}
cache.save_cache(sample_recipes, image_id_map=image_id_map)
loaded = cache.load_cache()
assert loaded is not None
assert loaded.image_id_map == image_id_map
def test_load_without_image_id_map_returns_empty_dict(self, temp_db_path, sample_recipes):
"""Loading from a cache that has no image_id_map metadata must yield {}."""
cache = PersistentRecipeCache(db_path=temp_db_path)
# Save without image_id_map
cache.save_cache(sample_recipes)
loaded = cache.load_cache()
assert loaded is not None
assert loaded.image_id_map == {}
def test_save_cache_without_image_id_map_does_not_corrupt_existing(
self, temp_db_path, sample_recipes,
):
"""Overwriting cache without passing image_id_map must not leave stale data.
The previous image_id_map entry in cache_metadata should be replaced with {}.
"""
cache = PersistentRecipeCache(db_path=temp_db_path)
cache.save_cache(sample_recipes, image_id_map={"123": "old-recipe"})
# Overwrite without image_id_map
cache.save_cache(sample_recipes)
loaded = cache.load_cache()
assert loaded.image_id_map == {}
def test_image_id_map_survives_recipe_update(self, temp_db_path, sample_recipes):
"""Updating a single recipe must not drop the image_id_map metadata."""
cache = PersistentRecipeCache(db_path=temp_db_path)
cache.save_cache(sample_recipes, image_id_map={"123": "recipe-alpha"})
updated = dict(sample_recipes[0])
updated["title"] = "Updated"
cache.update_recipe(updated)
loaded = cache.load_cache()
assert loaded.image_id_map == {"123": "recipe-alpha"}
def test_save_image_id_map_persists_without_full_save(self, temp_db_path, sample_recipes):
"""save_image_id_map must update cache_metadata without rewriting all recipes."""
cache = PersistentRecipeCache(db_path=temp_db_path)
cache.save_cache(sample_recipes)
cache.save_image_id_map({"555": "new-recipe", "666": "another-recipe"})
loaded = cache.load_cache()
assert loaded.image_id_map == {"555": "new-recipe", "666": "another-recipe"}
def test_save_image_id_map_overwrites_previous(self, temp_db_path, sample_recipes):
"""Calling save_image_id_map twice must replace, not merge."""
cache = PersistentRecipeCache(db_path=temp_db_path)
cache.save_cache(sample_recipes, image_id_map={"111": "old"})
cache.save_image_id_map({"222": "new-only"})
loaded = cache.load_cache()
assert loaded.image_id_map == {"222": "new-only"}