fix(lora): show LoRA-linked media on the Library page and in the inspector (#355)

Library previews, the positive detail and the per-prompt images page list
a prompt's linked media under the output folders plus, while the LoRA
integration is on, LoraManager's guarded folders and the download cache
(_media_roots; output root indices keep their meaning). That media keeps
its rooted identity, so Info and the media viewer open it; the path-based
serve route answers for those roots only with a file the database records
(either path spelling), never serves or makes a preview there, and output
requests never read LoraManager's layout. Such media is marked
linked_only: no preview file, no favorite star, no Tag action. Show in
folder accepts those roots; the download cache is named, not created.
This commit is contained in:
Vito Sansevero
2026-10-26 19:43:14 -07:00
parent 07b8189005
commit 1e7931ef45
5 changed files with 311 additions and 17 deletions
+71 -7
View File
@@ -2295,6 +2295,25 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(e)}, status=500
)
def _media_roots(self, outputs=None):
"""The roots a prompt's linked media is listed and inspected under.
The output folders first (``outputs``, else ``_get_all_output_dirs``),
so every output ``root_index`` keeps its meaning, then, while the
LoRA integration is on, the guarded LoraManager folders and the
CivitAI download cache (``_lora_image_dirs``, #355). Media under those
is linked-only: ``serve_output_image`` serves a file under them only
when the database records it, and no preview file is made there.
"""
roots = list(self._get_all_output_dirs() if outputs is None else outputs)
seen = {_canonical(root) for root in roots}
for extra in self._lora_image_dirs():
key = _canonical(extra)
if key not in seen:
seen.add(key)
roots.append(Path(extra))
return roots
def _lora_image_dirs(self):
"""Extra directories served when the LoRA integration is enabled.
@@ -2330,7 +2349,8 @@ class ImageRoutesMixin:
layout = lora_manager_layout("")
dirs = [Path(d) for d in layout.media_roots]
dirs.append(Path(get_lora_image_cache_dir()))
# Named, not made: listing and serving never create the cache.
dirs.append(Path(get_lora_image_cache_dir(create=False)))
return dirs
except Exception:
# LoRA integration is optional; never widen access on failure.
@@ -2361,9 +2381,21 @@ class ImageRoutesMixin:
return []
async def serve_output_image(self, request):
"""Serve a media file from the ComfyUI output folder(s)."""
"""Serve a media file from the ComfyUI output folder(s).
With ``?root=N`` past the output folders (a LoRA-linked root,
``_media_roots``, #355) only a file the database records is served,
and no preview is served or made there.
"""
try:
roots = list(self._get_all_output_dirs())
outputs = list(self._get_all_output_dirs())
root_param = request.query.get("root")
roots = outputs
if root_param is not None and re.fullmatch(r"[0-9]+", root_param):
if int(root_param) >= len(outputs):
# Only a LoRA-linked request reads LoraManager's layout;
# output media (and its previews) never waits on it.
roots = await self._run_in_executor(self._media_roots, outputs)
scope_error = self._gallery_scope_error(request.query, roots)
if scope_error is not None:
return scope_error
@@ -2378,9 +2410,11 @@ class ImageRoutesMixin:
):
return _forbidden("Only media files can be served")
output_dirs = self._select_root(roots, request.query.get("root"))
output_dirs = self._select_root(roots, root_param)
if not output_dirs:
return _forbidden("No allowed image directories configured")
if len(outputs) <= int(root_param or 0):
return await self._serve_linked_only(output_dirs[0], filepath)
for output_path in output_dirs:
image_path = output_path / filepath
@@ -2412,6 +2446,32 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(e)}, status=500
)
async def _serve_linked_only(self, root, filepath):
"""A file under a LoRA-linked root, served only when it is recorded."""
if _is_thumbnail_cache_path(filepath):
return _forbidden()
image_path = root / filepath
if not _is_within(image_path, root):
return _forbidden()
if not image_path.is_file():
return web.json_response(
{"success": False, "error": "Image file not found"}, status=404
)
def recorded() -> bool:
# Imported local media is stored resolved; downloaded CivitAI
# images keep the cache folder's own spelling (a symlinked or
# junctioned data folder), so either names the record.
spellings = dict.fromkeys([str(image_path), os.path.realpath(image_path)])
return any(
self.db.get_prompt_id_for_image(spelling) is not None
for spelling in spellings
)
if not await self._run_in_executor(recorded):
return _forbidden()
return self._file_response(image_path)
async def rescan_gallery(self, request):
"""Rewalk every output root now and answer with the new listing.
@@ -3266,7 +3326,8 @@ class ImageRoutesMixin:
if error is not None:
return error
index, scope, relative = target
roots = list(self._get_all_output_dirs())
# A LoRA-linked root too (#355): Show in folder only opens a folder.
roots = await self._run_in_executor(self._media_roots)
error = self._gallery_scope_error(
{"root_scope": scope, "root": str(index)}, roots
)
@@ -3393,8 +3454,11 @@ class ImageRoutesMixin:
return set()
async def inspect_image(self, request):
"""Read prompt evidence for one explicitly rooted physical media file."""
roots = list(self._get_all_output_dirs())
"""Read prompt evidence for one explicitly rooted physical media file.
The roots include LoRA-linked folders (``_media_roots``, #355), so the
inspector opens a LoRA import's media; it only reads them."""
roots = await self._run_in_executor(self._media_roots)
scope_error = self._gallery_scope_error(request.query, roots)
if scope_error is not None:
return scope_error
+36 -8
View File
@@ -234,9 +234,22 @@ class LibraryRoutesMixin:
)
def _library_media(
self, records: list[dict], roots: list[Path], *, linked: bool = True
self,
records: list[dict],
roots: list[Path],
*,
linked: bool = True,
outputs: int | None = None,
) -> list[dict]:
"""Workspace descriptors for a prompt's linked media under ``roots``.
Roots from index ``outputs`` on are linked-only (LoRA media, #355):
their media keeps its rooted identity, but gets no preview file (the
card loads the original; a video shows its label), no favorite star
and is marked ``linked_only`` (no Tag action).
"""
page = []
linked_only = set()
for record in records:
try:
raw_path = Path(record["image_path"])
@@ -250,10 +263,25 @@ class LibraryRoutesMixin:
canonical_root = _canonical(root)
if path.is_relative_to(canonical_root):
page.append((path, 0, canonical_root, index))
if outputs is not None and index >= outputs:
relative = path.relative_to(canonical_root).as_posix()
linked_only.add((index, relative))
break
except (OSError, ValueError, RuntimeError):
continue
return self._format_workspace_page(page, linked=linked)
images = self._format_workspace_page(page, linked=linked)
for image in images:
if (image["root_index"], image["path"]) in linked_only:
image["thumbnail_url"] = None
image["thumbnail_ready"] = False
image["linked_only"] = True
image.pop("favorite", None)
return images
def _library_roots(self) -> tuple[list[Path], int]:
"""``(roots, number of output roots)`` for a prompt's linked media."""
outputs = list(self._get_all_output_dirs())
return self._media_roots(outputs), len(outputs)
@pair_response
async def get_library_options(self, request: web.Request) -> web.Response:
@@ -294,13 +322,13 @@ class LibraryRoutesMixin:
return result, records
result, records = await self._run_in_executor(read)
roots = list(self._get_all_output_dirs())
def previews() -> None:
roots, outputs = self._library_roots()
for parent in result["items"]:
# Previews belong to their parent; a per-parent link read is N+1.
parent["previews"] = self._library_media(
records[parent["id"]], roots, linked=False
records[parent["id"]], roots, linked=False, outputs=outputs
)
await self._run_in_executor(previews)
@@ -342,9 +370,9 @@ class LibraryRoutesMixin:
result, records = await self._run_in_executor(read)
if result is None:
return _missing()
roots = list(self._get_all_output_dirs())
roots, outputs = await self._run_in_executor(self._library_roots)
result["positive"]["previews"] = await self._run_in_executor(
self._library_media, records, roots
lambda: self._library_media(records, roots, outputs=outputs)
)
return web.json_response({"success": True, **result})
@@ -358,7 +386,7 @@ class LibraryRoutesMixin:
archive = _archive_scope(request)
limit = _page_limit(request)
cursor = request.query.get("cursor")
roots = list(self._get_all_output_dirs())
roots, outputs = await self._run_in_executor(self._library_roots)
canonical_roots = [str(_canonical(root)) for root in roots]
def read() -> dict | None:
@@ -391,7 +419,7 @@ class LibraryRoutesMixin:
if result is None:
return _missing()
result["images"] = await self._run_in_executor(
self._library_media, result["images"], roots
lambda: self._library_media(result["images"], roots, outputs=outputs)
)
return web.json_response({"success": True, **result})