From f362ed585b23e488b467a6c4bcf00b026ccde107 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 10 Jul 2026 20:55:22 +0800 Subject: [PATCH] fix(preview): gracefully handle deleted preview files - image fallback, cache cleanup, quieter logs - Add onerror handler on previews to fallback to no-preview.png - Fire async cache cleanup when preview file returns 404 - Add ModelCache.clear_preview_by_path() for safe stale-url removal - Downgrade /api/lm/previews 404 log from warning to debug --- py/middleware/error_middleware.py | 7 ++++- py/routes/handlers/preview_handlers.py | 31 +++++++++++++++++++ py/services/model_cache.py | 23 +++++++++++++- static/js/components/RecipeCard.js | 2 +- static/js/components/RecipeModal.js | 4 +-- static/js/components/shared/ModelCard.js | 2 +- .../js/components/shared/ModelVersionsTab.js | 2 +- 7 files changed, 64 insertions(+), 7 deletions(-) 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 {
${isVideo ? `` : - `${this.recipe.title}` + `${this.recipe.title}` }
diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 7f7e458c..4c962305 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -757,7 +757,7 @@ class RecipeModal { `` : - `LoRA preview`; + `LoRA preview`; let loraItemClass = 'recipe-lora-item'; if (existsLocally) { @@ -1606,7 +1606,7 @@ class RecipeModal { - ` : `Checkpoint preview`; + ` : `Checkpoint preview`; const badge = existsLocally ? `
diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index 051e701a..5288f7d3 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -643,7 +643,7 @@ export function createModelCard(model, modelType) {
${isVideo ? `` : - `${model.model_name}` + `${model.model_name}` }
${shouldBlur ? diff --git a/static/js/components/shared/ModelVersionsTab.js b/static/js/components/shared/ModelVersionsTab.js index ba56e6e4..135ca630 100644 --- a/static/js/components/shared/ModelVersionsTab.js +++ b/static/js/components/shared/ModelVersionsTab.js @@ -432,7 +432,7 @@ function renderMediaMarkup(version) { return `
- ${escapeHtml(version.name || 'preview')} + ${escapeHtml(version.name || 'preview')}
`; }