Files
ComfyUI-Lora-Manager/py/routes/handlers/preview_handlers.py
T
Will Miao 470d85cca6 feat(scanner): scan a single root and keep unreachable entries (#1108)
Refreshing had no way to say "scan only this drive": a user with three external
drives had to spin all of them up for every refresh, and switching a drive off
made the next refresh treat its whole library as deleted (rows pruned from the
memory cache and the SQLite cache, preview_url stripped on the next scroll).

Backend (py/services/model_scanner.py, py/config.py):
* ReconcileScope(roots, folder) + _reconcile_cache(scope=...): files inside the
  scope reconcile normally, everything outside is neither re-read nor removed.
  The folder half is plumbing for the sidebar entry in the next change.
* Path-level pruning guard: cached entries under a path this walk could not
  read are kept and reported instead of removed. Sources: a configured root that
  is not reachable (drive switched off while LM runs), a directory os.walk
  failed to enter (permissions / I/O error / Windows junction to an offline
  drive), and a known first-level symlink whose target is gone
  (Config.iter_path_mappings()).
* The recorded folder list is unioned instead of replaced whenever the scan did
  not verify every root, so a scoped scan cannot empty the sidebar.
* _reconcile_cache returns a summary (added / removed / repaired /
  scanned_roots / skipped_roots / unavailable_paths / kept_unreachable),
  exposed as ModelScanner.last_reconcile_summary, returned by
  BaseModelService.scan_models() and broadcast in the completed WS payload.
* _root_display_labels(): set-aware labels ("G: loras", "usb/loras") grown
  leftwards with real parent segments until unique, shared by the walk-progress
  line and the roots API.
* GET /scan accepts repeated `roots` (400 for unknown roots, 400 combined with
  full_rebuild=true); GET /roots gains root_details (label / reachable / cached
  count) while `roots` stays a plain path list for existing callers.
* serve_preview: a 404 no longer clears the cached preview_url when the file's
  own directory is unreachable - browsing the grid with a drive off used to
  strip preview references from the persistent cache.

Frontend:
* Refresh ▾ gains a "Scan one folder" section listing the page's roots with
  their cached counts; offline roots stay clickable and explain themselves; rows
  are wired by delegation (new static/js/components/controls/ScanScopeMenu.js).
* A scoped scan reports "Scanned <root>: N new, M removed"; a scan that kept
  entries reports "<N> models kept: <paths> not reachable".
* registerAPI() now injects the two cross-page passthroughs (fetchModelRoots and
  an argument-forwarding refreshModels) so a page facade cannot drop them: the
  first version rendered an empty menu and would have run a full refresh.
* createToastElement whitelists toast types, so a wrong `type` argument degrades
  to the info style instead of rendering an unstyled box.

Verified in a sandbox instance with three roots: a scoped scan walks only the
requested root (progress roots=0/1, 240 files); a full refresh with one root
offline reports kept_unreachable=60 and leaves all 420 models cached; /roots
reports the offline root with its cached count. 3699 passed, 7 skipped;
frontend 1488 passed (148 files); vue widgets 96 passed.
2026-10-07 15:01:26 +08:00

155 lines
6.5 KiB
Python

"""Handlers responsible for serving preview assets dynamically."""
from __future__ import annotations
import asyncio
import logging
import mimetypes
import urllib.parse
from pathlib import Path
from aiohttp import web
from ...config import config as global_config
logger = logging.getLogger(__name__)
_CHUNK_SIZE = 1024 * 1024 # 1 MB — balance between streaming iteration overhead and per-chunk memory
# Video file extensions that bypass native sendfile on Windows
# to avoid IOCP/ProactorEventLoop crashes during client disconnect.
_VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".mov", ".avi", ".mkv"})
class PreviewHandler:
"""Serve preview assets for the active library at request time."""
def __init__(self, *, config=global_config) -> None:
self._config = config
async def serve_preview(self, request: web.Request) -> web.StreamResponse:
"""Return the preview file referenced by the encoded ``path`` query."""
raw_path = request.query.get("path", "")
if not raw_path:
raise web.HTTPBadRequest(text="Missing 'path' query parameter")
try:
decoded_path = urllib.parse.unquote(raw_path)
except Exception as exc: # pragma: no cover - defensive guard
logger.debug("Failed to decode preview path %s: %s", raw_path, exc)
raise web.HTTPBadRequest(text="Invalid preview path encoding") from exc
normalized = decoded_path.replace("\\", "/")
if not self._config.is_preview_path_allowed(normalized):
raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
candidate = Path(normalized)
try:
resolved = candidate.expanduser().resolve(strict=False)
except Exception as exc:
logger.debug("Failed to resolve preview path %s: %s", normalized, exc)
raise web.HTTPBadRequest(text="Unable to resolve preview path") from exc
if not resolved.is_file():
logger.debug("Preview file not found at %s", str(resolved))
if resolved.parent.is_dir():
# The file is really gone from a reachable directory, so the
# cached preview_url is stale and can be cleared.
asyncio.create_task(self._cleanup_stale_preview_url(normalized))
else:
# The directory itself is unreachable (drive switched off,
# unmounted share). Nothing was deleted: keep the cached
# preview_url so the card recovers when the drive is back.
logger.debug(
"Preview directory unreachable, keeping cached preview_url: %s",
str(resolved.parent),
)
raise web.HTTPNotFound(text="Preview file not found")
# aiohttp's FileResponse handles range requests, content headers, and
# uses kernel sendfile (zero-copy DMA) on Linux/macOS. On Windows it
# uses IOCP-based _sendfile_native which can crash when the client
# disconnects mid-transfer during fast scrolling. The _stream_file()
# fallback is kept for a future compat toggle.
#
# Set explicit Cache-Control so the browser can cache video (and image)
# previews across VirtualScroller recycling cycles. Without this,
# Chrome does not cache 206 Partial Content responses for <video>
# elements, causing the same video to be re-downloaded on every scroll.
resp = web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
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:
"""Stream a file chunk-by-chunk, bypassing native sendfile.
This avoids the Windows IOCP ``_sendfile_native`` crash that occurs
when the client disconnects during a large file transfer.
"""
content_type, _ = mimetypes.guess_type(str(path))
if content_type is None:
content_type = "application/octet-stream"
file_size = path.stat().st_size
resp = web.StreamResponse()
resp.content_type = content_type
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)
try:
with open(path, "rb") as f:
while True:
chunk = f.read(_CHUNK_SIZE)
if not chunk:
break
await resp.write(chunk)
except (ConnectionResetError, ConnectionAbortedError):
# Client disconnected during streaming — expected when scrolling
# rapidly through a library with animated previews.
pass
except OSError as exc:
logger.debug("I/O error streaming preview %s: %s", path, exc)
return resp
__all__ = ["PreviewHandler"]