mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-07-13 20:21:16 -03:00
fix(preview): gracefully handle deleted preview files - image fallback, cache cleanup, quieter logs
- Add onerror handler on <img> 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
This commit is contained in:
@@ -41,7 +41,12 @@ async def api_json_error(
|
|||||||
if exc.status < 400:
|
if exc.status < 400:
|
||||||
raise
|
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",
|
"API %s %s returned HTTP %d: %s",
|
||||||
request.method,
|
request.method,
|
||||||
request.path,
|
request.path,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -53,6 +54,7 @@ class PreviewHandler:
|
|||||||
|
|
||||||
if not resolved.is_file():
|
if not resolved.is_file():
|
||||||
logger.debug("Preview file not found at %s", str(resolved))
|
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")
|
raise web.HTTPNotFound(text="Preview file not found")
|
||||||
|
|
||||||
# aiohttp's FileResponse handles range requests, content headers, and
|
# aiohttp's FileResponse handles range requests, content headers, and
|
||||||
@@ -69,6 +71,35 @@ class PreviewHandler:
|
|||||||
resp.headers["Cache-Control"] = "public, max-age=86400"
|
resp.headers["Cache-Control"] = "public, max-age=86400"
|
||||||
return resp
|
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(
|
async def _stream_file(
|
||||||
self, request: web.Request, path: Path
|
self, request: web.Request, path: Path
|
||||||
) -> web.StreamResponse:
|
) -> web.StreamResponse:
|
||||||
|
|||||||
@@ -337,4 +337,25 @@ class ModelCache:
|
|||||||
else:
|
else:
|
||||||
return False # Model not found
|
return False # Model not found
|
||||||
|
|
||||||
return True
|
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
|
||||||
@@ -358,7 +358,7 @@ class RecipeCard {
|
|||||||
<div class="delete-preview">
|
<div class="delete-preview">
|
||||||
${isVideo ?
|
${isVideo ?
|
||||||
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
|
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
|
||||||
`<img src="${previewUrl}" alt="${this.recipe.title}">`
|
`<img src="${previewUrl}" alt="${this.recipe.title}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="delete-info">
|
<div class="delete-info">
|
||||||
|
|||||||
@@ -757,7 +757,7 @@ class RecipeModal {
|
|||||||
`<video class="thumbnail-video" autoplay loop muted playsinline>
|
`<video class="thumbnail-video" autoplay loop muted playsinline>
|
||||||
<source src="${lora.preview_url}" type="video/mp4">
|
<source src="${lora.preview_url}" type="video/mp4">
|
||||||
</video>` :
|
</video>` :
|
||||||
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`;
|
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`;
|
||||||
|
|
||||||
let loraItemClass = 'recipe-lora-item';
|
let loraItemClass = 'recipe-lora-item';
|
||||||
if (existsLocally) {
|
if (existsLocally) {
|
||||||
@@ -1606,7 +1606,7 @@ class RecipeModal {
|
|||||||
<video class="thumbnail-video" autoplay loop muted playsinline>
|
<video class="thumbnail-video" autoplay loop muted playsinline>
|
||||||
<source src="${previewUrl}" type="video/mp4">
|
<source src="${previewUrl}" type="video/mp4">
|
||||||
</video>
|
</video>
|
||||||
` : `<img src="${previewUrl}" alt="Checkpoint preview">`;
|
` : `<img src="${previewUrl}" alt="Checkpoint preview" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`;
|
||||||
|
|
||||||
const badge = existsLocally ? `
|
const badge = existsLocally ? `
|
||||||
<div class="local-badge">
|
<div class="local-badge">
|
||||||
|
|||||||
@@ -643,7 +643,7 @@ export function createModelCard(model, modelType) {
|
|||||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||||
${isVideo ?
|
${isVideo ?
|
||||||
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
|
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
|
||||||
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
|
`<img src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||||
}
|
}
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
${shouldBlur ?
|
${shouldBlur ?
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ function renderMediaMarkup(version) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="version-media">
|
<div class="version-media">
|
||||||
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(version.name || 'preview')}">
|
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(version.name || 'preview')}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user