diff --git a/py/middleware/error_middleware.py b/py/middleware/error_middleware.py index cea0332f..e6f99051 100644 --- a/py/middleware/error_middleware.py +++ b/py/middleware/error_middleware.py @@ -41,7 +41,12 @@ async def api_json_error( if exc.status < 400: raise - logger.warning( + # Preview 404 is routine (file deleted from disk) — not worth a warning. + logger_method = logger.warning + if request.path.startswith("/api/lm/previews") and exc.status == 404: + logger_method = logger.debug + + logger_method( "API %s %s returned HTTP %d: %s", request.method, request.path, diff --git a/py/routes/handlers/preview_handlers.py b/py/routes/handlers/preview_handlers.py index 1ddf43a7..c4a9edf4 100644 --- a/py/routes/handlers/preview_handlers.py +++ b/py/routes/handlers/preview_handlers.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging import mimetypes import urllib.parse @@ -53,6 +54,7 @@ class PreviewHandler: if not resolved.is_file(): logger.debug("Preview file not found at %s", str(resolved)) + asyncio.create_task(self._cleanup_stale_preview_url(normalized)) raise web.HTTPNotFound(text="Preview file not found") # aiohttp's FileResponse handles range requests, content headers, and @@ -69,6 +71,35 @@ class PreviewHandler: resp.headers["Cache-Control"] = "public, max-age=86400" return resp + async def _cleanup_stale_preview_url(self, normalized_preview_path: str) -> None: + """Fire-and-forget: clear stale preview_url from all model caches. + + When a preview file is no longer on disk, remove its reference from + every cached entry so subsequent list API responses return an empty + ``preview_url``, letting the frontend show the no-preview placeholder. + """ + try: + from ...services.service_registry import ServiceRegistry + + for service_name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"): + scanner = ServiceRegistry.get_service_sync(service_name) + if scanner is None or not hasattr(scanner, "_cache"): + continue + cache = getattr(scanner, "_cache", None) + if cache is None or not hasattr(cache, "clear_preview_by_path"): + continue + cleared = await cache.clear_preview_by_path(normalized_preview_path) + if cleared and hasattr(scanner, "_persist_current_cache"): + await scanner._persist_current_cache() + logger.info( + "Cleared stale preview_url for %d %s entries (%s)", + cleared, + service_name, + normalized_preview_path, + ) + except Exception as exc: + logger.debug("Failed to clean up stale preview_url: %s", exc) + async def _stream_file( self, request: web.Request, path: Path ) -> web.StreamResponse: diff --git a/py/services/model_cache.py b/py/services/model_cache.py index ba515115..f76e6ab7 100644 --- a/py/services/model_cache.py +++ b/py/services/model_cache.py @@ -337,4 +337,25 @@ class ModelCache: else: return False # Model not found - return True \ No newline at end of file + return True + + async def clear_preview_by_path(self, preview_file_path: str) -> int: + """Clear ``preview_url`` for every cached entry referencing a file path. + + When a preview file has been deleted from disk, this removes its + reference from all matching cache entries so the next list-API + response returns an empty ``preview_url`` instead of a stale URL + that produces 404s. + + Returns the number of entries that were updated. + """ + normalized = preview_file_path.replace("\\", "/") + cleared = 0 + async with self._lock: + for item in self.raw_data: + cached_url = item.get("preview_url", "") + if cached_url.replace("\\", "/") == normalized: + item["preview_url"] = "" + item["preview_nsfw_level"] = 0 + cleared += 1 + return cleared \ No newline at end of file diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index 4cd88428..cd1617bf 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -358,7 +358,7 @@ class RecipeCard {
`;
+ `
`;
let loraItemClass = 'recipe-lora-item';
if (existsLocally) {
@@ -1606,7 +1606,7 @@ class RecipeModal {
- ` : `