feat: multi-directory gallery scan with folder filter (#126) (#135)

* refactor(api): add multi-root output dir infrastructure (#126)

- Change _gallery_cache from None to {} (dict keyed by path string)
- Update invalidate_gallery_cache() to clear dict instead of setting None
- Add _get_all_output_dirs() helper as single source of truth for
  resolving configured + fallback output directories
- Update _enrich_prompt_images() to iterate all output roots when
  computing relative paths and thumbnail URLs

* feat(api): support multiple gallery scan paths in settings endpoint (#126)

* feat(ui): dynamic multi-path settings with add/remove buttons (#126)

* cleanup: remove dead galleryRootPath assignment in loadSettings

* feat(api): multi-directory gallery scan with subfolder filter and multi-root serve (#126)

* feat(db): add folder filter to search_prompts and get_prompt_subfolders (#126)

* feat(api): add subfolders endpoint and folder filter to prompt search (#126)

* feat(ui): add folder dropdown filter to search bar (#126)

* fix: escape LIKE wildcards in folder filter and remove dead _gallery_cache_time field

* feat(scan): scan all media types across all output dirs with batched extraction (#126)

* chore: gitignore SQLite WAL-mode temp files

* fix: enrich prompt images with URLs and check all output dirs for serving

- Extract _enrich_images() for flat image lists, used by get_prompt_images
- get_prompt_images now adds url/thumbnail_url (fixes stuck "Loading images...")
- serve_image checks all output dirs not just first (fixes 403 on multi-path)
- Fix diagnostics monitor check to read current module attribute, not stale import

* feat(ui): add version-scoped update notice popup for rescan (#126)

* chore: bump version to 3.2.0
This commit is contained in:
Vito
2026-04-03 06:46:01 -07:00
committed by GitHub
parent 2cf9f42e73
commit 6fc6e683eb
9 changed files with 629 additions and 268 deletions
+3
View File
@@ -13,6 +13,9 @@ docs/
logs/
standalone_tagger.py
prompts.db
prompts.db-shm
prompts.db-wal
prompts.db-journal
# Byte-compiled / optimized / DLL files
__pycache__/
+62
View File
@@ -187,6 +187,7 @@ class PromptDatabase:
rating_max: Optional[int] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
folder: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> List[Dict[str, Any]]:
@@ -201,6 +202,7 @@ class PromptDatabase:
rating_max: Maximum rating filter
date_from: Start date filter (ISO format)
date_to: End date filter (ISO format)
folder: Filter by subfolder name in generated image paths
limit: Maximum number of results
offset: Number of results to skip
@@ -227,6 +229,26 @@ class PromptDatabase:
)
params.append(tag)
if folder:
# Escape LIKE wildcards in the folder name
safe_folder = (
folder.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
)
query_parts.append(
"AND prompts.id IN ("
" SELECT DISTINCT prompt_id FROM generated_images"
" WHERE image_path LIKE ? ESCAPE '\\'"
" OR image_path LIKE ? ESCAPE '\\'"
" OR image_path LIKE ? ESCAPE '\\'"
")"
)
# Pattern 1: folder between forward slashes (Unix + normalized Windows)
params.append(f"%/{safe_folder}/%")
# Pattern 2: folder between backslashes (Windows native)
params.append(f"%\\\\{safe_folder}\\\\%")
# Pattern 3: folder at start of a relative path
params.append(f"{safe_folder}/%")
if rating_min is not None:
query_parts.append("AND rating >= ?")
params.append(rating_min)
@@ -435,6 +457,46 @@ class PromptDatabase:
)
return [row["category"] for row in cursor.fetchall()]
def get_prompt_subfolders(self, root_dirs: Optional[List[str]] = None) -> List[str]:
"""
Get distinct subfolder paths from generated_images.
Extracts the directory portion of image_path, made relative to
root_dirs if provided. Returns sorted unique folder names.
"""
with self.model.get_connection() as conn:
cursor = conn.execute(
"SELECT DISTINCT image_path FROM generated_images "
"WHERE image_path IS NOT NULL AND image_path != ''"
)
folders = set()
for row in cursor.fetchall():
image_path = row["image_path"]
parent = os.path.dirname(image_path)
if not parent:
continue
made_relative = False
if root_dirs:
for root in root_dirs:
try:
rel = os.path.relpath(parent, root)
if not rel.startswith(".."):
if rel != ".":
folders.add(rel)
made_relative = True
break
except ValueError:
continue
if not made_relative:
basename = os.path.basename(parent)
if basename:
folders.add(basename)
return sorted(folders)
def get_all_tags(self) -> List[str]:
"""
Get all unique tags that are in use (linked to at least one prompt).
+67 -41
View File
@@ -123,8 +123,7 @@ class PromptManagerAPI(
self.db = PromptDatabase()
self._cached_output_dir = None # Lazy-cached by _find_comfyui_output_dir()
self._html_cache = {} # Cached HTML file contents keyed by path
self._gallery_cache = None # Cached gallery file listing (Fix 2.4)
self._gallery_cache_time = 0 # Timestamp of last cache fill
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
self._gallery_cache_ttl = 30 # Cache TTL in seconds
# Run cleanup on initialization to remove any existing duplicates
@@ -156,8 +155,7 @@ class PromptManagerAPI(
Called by the image monitor when new files are detected.
"""
self._gallery_cache = None
self._gallery_cache_time = 0
self._gallery_cache = {}
def add_routes(self, routes):
"""Register all API routes with the ComfyUI server.
@@ -391,47 +389,52 @@ class PromptManagerAPI(
# ── Shared utilities used by multiple mixins ──────────────────────
def _enrich_prompt_images(self, prompts):
"""Add url and thumbnail_url to each image in prompt results."""
def _enrich_images(self, images):
"""Add url and thumbnail_url to a flat list of image dicts."""
from urllib.parse import quote as url_quote
output_dir = self._find_comfyui_output_dir()
output_path = Path(output_dir) if output_dir else None
output_dirs = self._get_all_output_dirs()
for image in images:
image_path_str = image.get("image_path", "")
if not image_path_str:
continue
img_path = Path(image_path_str)
# Set fallback url via image ID
if image.get("id"):
image["url"] = f"/prompt_manager/images/{image['id']}/file"
# Try each root to compute relative path and thumbnail URL
for output_path in output_dirs:
try:
rel_path = img_path.resolve().relative_to(output_path.resolve())
image["relative_path"] = str(rel_path)
image["url"] = (
f"/prompt_manager/images/serve/{url_quote(rel_path.as_posix(), safe='/')}"
)
# Check for thumbnail
rel_no_ext = rel_path.with_suffix("")
thumb_rel = (
f"thumbnails/{rel_no_ext.as_posix()}_thumb{rel_path.suffix}"
)
thumb_abs = output_path / thumb_rel
if thumb_abs.exists():
image["thumbnail_url"] = (
f"/prompt_manager/images/serve/{url_quote(thumb_rel, safe='/')}"
)
break # Found matching root, stop searching
except (ValueError, RuntimeError):
continue # Try next root
return images
def _enrich_prompt_images(self, prompts):
"""Add url and thumbnail_url to each image in prompt results."""
for prompt in prompts:
for image in prompt.get("images", []):
image_path_str = image.get("image_path", "")
if not image_path_str:
continue
img_path = Path(image_path_str)
# Set fallback url via image ID
if image.get("id"):
image["url"] = f"/prompt_manager/images/{image['id']}/file"
# Try to compute relative path and thumbnail URL
if output_path:
try:
rel_path = img_path.resolve().relative_to(output_path.resolve())
image["relative_path"] = str(rel_path)
image["url"] = (
f"/prompt_manager/images/serve/{url_quote(rel_path.as_posix(), safe='/')}"
)
# Check for thumbnail
rel_no_ext = rel_path.with_suffix("")
thumb_rel = (
f"thumbnails/{rel_no_ext.as_posix()}_thumb{rel_path.suffix}"
)
thumb_abs = output_path / thumb_rel
if thumb_abs.exists():
image["thumbnail_url"] = (
f"/prompt_manager/images/serve/{url_quote(thumb_rel, safe='/')}"
)
except (ValueError, RuntimeError):
pass
self._enrich_images(prompt.get("images", []))
return prompts
def _clean_nan_recursive(self, obj):
@@ -550,6 +553,29 @@ class PromptManagerAPI(
return None
def _get_all_output_dirs(self):
"""Get all configured output directories, falling back to auto-detect.
Returns:
List[Path]: Valid output directory paths, possibly empty.
"""
from ..config import GalleryConfig
output_dirs = []
if GalleryConfig.MONITORING_DIRECTORIES:
for d in GalleryConfig.MONITORING_DIRECTORIES:
p = Path(d).resolve()
if p.is_dir():
output_dirs.append(p)
# Fallback to auto-detect if no configured dirs are valid
if not output_dirs:
fallback = self._find_comfyui_output_dir()
if fallback:
output_dirs.append(Path(fallback))
return output_dirs
def _extract_comfyui_metadata(self, image_path):
"""Extract ComfyUI workflow metadata from PNG image files."""
try:
+187 -146
View File
@@ -371,6 +371,9 @@ class AdminRoutesMixin:
"settings": {
"result_timeout": PromptManagerConfig.RESULT_TIMEOUT,
"webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE,
"gallery_root_paths": list(
GalleryConfig.MONITORING_DIRECTORIES
),
"gallery_root_path": (
GalleryConfig.MONITORING_DIRECTORIES[0]
if GalleryConfig.MONITORING_DIRECTORIES
@@ -400,8 +403,68 @@ class AdminRoutesMixin:
if "webui_display_mode" in data:
PromptManagerConfig.WEBUI_DISPLAY_MODE = data["webui_display_mode"]
# Handle gallery root path
if "gallery_root_path" in data:
# Blocked system directories (shared by both path handlers)
blocked = [
"/etc",
"/usr",
"/bin",
"/sbin",
"/boot",
"/proc",
"/sys",
"/dev",
"/var/log",
"/root",
"C:\\Windows",
"C:\\Program Files",
]
# Handle gallery root paths (array — preferred)
if "gallery_root_paths" in data:
new_paths = data["gallery_root_paths"]
if not isinstance(new_paths, list):
return web.json_response(
{
"success": False,
"error": "gallery_root_paths must be a list",
},
status=400,
)
validated_paths = []
for path_str in new_paths:
path_str = path_str.strip()
if not path_str:
continue
resolved = Path(path_str).resolve()
if not resolved.is_dir():
return web.json_response(
{
"success": False,
"error": f"Path does not exist or is not a directory: {path_str}",
},
status=400,
)
for b in blocked:
if str(resolved).startswith(b):
return web.json_response(
{
"success": False,
"error": f"Cannot use system directory: {path_str}",
},
status=400,
)
validated_paths.append(path_str)
old_paths = list(GalleryConfig.MONITORING_DIRECTORIES)
if validated_paths != old_paths:
GalleryConfig.MONITORING_DIRECTORIES = validated_paths
self._cached_output_dir = None
self._gallery_cache = {}
restart_required = True
elif "gallery_root_path" in data:
# Backward compat: single path string
new_path = data["gallery_root_path"].strip()
old_path = (
GalleryConfig.MONITORING_DIRECTORIES[0]
@@ -411,9 +474,7 @@ class AdminRoutesMixin:
if new_path != old_path:
if new_path:
from pathlib import Path as _Path
resolved = _Path(new_path).resolve()
resolved = Path(new_path).resolve()
if not resolved.is_dir():
return web.json_response(
{
@@ -422,20 +483,6 @@ class AdminRoutesMixin:
},
status=400,
)
blocked = [
"/etc",
"/usr",
"/bin",
"/sbin",
"/boot",
"/proc",
"/sys",
"/dev",
"/var/log",
"/root",
"C:\\Windows",
"C:\\Program Files",
]
for b in blocked:
if str(resolved).startswith(b):
return web.json_response(
@@ -448,10 +495,8 @@ class AdminRoutesMixin:
GalleryConfig.MONITORING_DIRECTORIES = [new_path]
else:
GalleryConfig.MONITORING_DIRECTORIES = []
# Invalidate caches so next lookup uses new config
self._cached_output_dir = None
self._gallery_cache = None
self._gallery_cache_time = 0
self._gallery_cache = {}
restart_required = True
# Save to config file for persistence
@@ -599,10 +644,11 @@ class AdminRoutesMixin:
# Check image monitor status
try:
from ...utils.image_monitor import _monitor_instance
from ...utils import image_monitor as im_mod
if _monitor_instance is not None:
monitor_status = _monitor_instance.get_status()
monitor = im_mod._monitor_instance
if monitor is not None:
monitor_status = monitor.get_status()
results["image_monitor"] = {
"status": (
"ok" if monitor_status.get("observer_alive") else "error"
@@ -978,29 +1024,60 @@ class AdminRoutesMixin:
async def scan_images(self, request):
"""Scan ComfyUI output images for prompt metadata and add them to the database."""
BATCH_SIZE = 50 # Files per executor call for metadata extraction
def _collect_media_files(output_dirs):
"""Collect all media files from output directories (blocking I/O)."""
image_extensions = [".png", ".jpg", ".jpeg", ".webp", ".gif"]
video_extensions = [".mp4", ".webm", ".avi", ".mov", ".mkv", ".m4v", ".wmv"]
media_extensions = image_extensions + video_extensions
all_files = []
seen = set()
for output_dir in output_dirs:
for ext in media_extensions:
for pattern in [f"*{ext}", f"*{ext.upper()}"]:
for f in output_dir.rglob(pattern):
if "thumbnails" not in f.parts:
norm = str(f).lower()
if norm not in seen:
seen.add(norm)
all_files.append(f)
return all_files
def _extract_batch_metadata(file_batch):
"""Extract metadata from a batch of files in one executor call."""
results = []
for f in file_batch:
try:
meta = self._extract_comfyui_metadata(str(f))
results.append((f, meta))
except Exception:
results.append((f, {}))
return results
async def stream_response():
try:
self.logger.info("Starting image scan operation")
self.logger.info("Starting scan (timer clearing not implemented yet)")
output_dir = self._find_comfyui_output_dir()
if not output_dir:
self.logger.error("ComfyUI output directory not found")
yield f"data: {json.dumps({'type': 'error', 'message': 'ComfyUI output directory not found'})}\n\n"
output_dirs = self._get_all_output_dirs()
if not output_dirs:
self.logger.error("No output directories found")
yield f"data: {json.dumps({'type': 'error', 'message': 'No output directories found. Configure scan directories in Settings.'})}\n\n"
return
yield f"data: {json.dumps({'type': 'progress', 'progress': 0, 'status': 'Scanning for PNG files...', 'processed': 0, 'found': 0})}\n\n"
dir_names = [str(d) for d in output_dirs]
yield f"data: {json.dumps({'type': 'progress', 'progress': 0, 'status': f'Scanning {len(output_dirs)} directory(ies) for media files...', 'processed': 0, 'found': 0})}\n\n"
png_files = await self._run_in_executor(
lambda: list(Path(output_dir).rglob("*.png"))
media_files = await self._run_in_executor(
_collect_media_files, output_dirs
)
total_files = len(png_files)
total_files = len(media_files)
if total_files == 0:
yield f"data: {json.dumps({'type': 'complete', 'processed': 0, 'found': 0, 'added': 0})}\n\n"
yield f"data: {json.dumps({'type': 'complete', 'processed': 0, 'found': 0, 'added': 0, 'linked': 0, 'directories': dir_names})}\n\n"
return
yield f"data: {json.dumps({'type': 'progress', 'progress': 5, 'status': f'Found {total_files} PNG files to process...', 'processed': 0, 'found': 0})}\n\n"
yield f"data: {json.dumps({'type': 'progress', 'progress': 5, 'status': f'Found {total_files} media files to process...', 'processed': 0, 'found': 0})}\n\n"
processed_count = 0
found_count = 0
@@ -1018,133 +1095,97 @@ class AdminRoutesMixin:
sys.path.insert(0, current_dir)
from utils.hashing import generate_prompt_hash
for i, png_file in enumerate(png_files):
try:
metadata = await self._run_in_executor(
self._extract_comfyui_metadata, str(png_file)
)
processed_count += 1
# Process files in batches for performance
for batch_start in range(0, total_files, BATCH_SIZE):
batch = media_files[batch_start : batch_start + BATCH_SIZE]
if metadata:
self.logger.debug(
f"Found metadata in {os.path.basename(png_file)}: {list(metadata.keys())}"
)
# Extract metadata for entire batch in one executor call
batch_results = await self._run_in_executor(
_extract_batch_metadata, batch
)
for media_file, metadata in batch_results:
try:
processed_count += 1
if not metadata:
continue
parsed_data = self._parse_comfyui_prompt(metadata)
self.logger.debug(
f"Parsed data keys: {list(parsed_data.keys())}, has prompt: {bool(parsed_data.get('prompt'))}, has parameters: {bool(parsed_data.get('parameters'))}"
if not (
parsed_data.get("prompt")
or parsed_data.get("parameters")
):
continue
found_count += 1
prompt_text = self._extract_readable_prompt(parsed_data)
if prompt_text and not isinstance(prompt_text, str):
prompt_text = str(prompt_text)
if not (prompt_text and prompt_text.strip()):
continue
prompt_hash = generate_prompt_hash(prompt_text.strip())
existing = await self._run_in_executor(
self.db.get_prompt_by_hash, prompt_hash
)
if parsed_data.get("prompt") or parsed_data.get(
"parameters"
):
found_count += 1
prompt_text = self._extract_readable_prompt(parsed_data)
if prompt_text:
self.logger.debug(
f"Found prompt in {os.path.basename(png_file)} (type: {type(prompt_text)}): {str(prompt_text)[:100]}..."
if existing:
try:
await self._run_in_executor(
self.db.link_image_to_prompt,
existing["id"],
str(media_file),
)
else:
self.logger.debug(
f"No readable prompt found in {os.path.basename(png_file)}, parsed_data keys: {list(parsed_data.keys())}"
linked_count += 1
except Exception as e:
self.logger.error(
f"Failed to link {media_file.name} to existing prompt: {e}"
)
if prompt_text and not isinstance(prompt_text, str):
self.logger.debug(
f"Converting prompt_text from {type(prompt_text)} to string"
)
prompt_text = str(prompt_text)
if prompt_text and prompt_text.strip():
else:
prompt_id = await self._run_in_executor(
self.db.save_prompt,
prompt_text.strip(),
"scanned",
["auto-scanned"],
None,
f"Auto-scanned from {media_file.name}",
prompt_hash,
)
if prompt_id:
added_count += 1
try:
prompt_hash = generate_prompt_hash(
prompt_text.strip()
await self._run_in_executor(
self.db.link_image_to_prompt,
prompt_id,
str(media_file),
)
self.logger.debug(
f"Generated hash for prompt: {prompt_hash[:16]}..."
)
existing = await self._run_in_executor(
self.db.get_prompt_by_hash, prompt_hash
)
if existing:
self.logger.debug(
f"Found existing prompt ID {existing['id']} for image {os.path.basename(png_file)}"
)
try:
await self._run_in_executor(
self.db.link_image_to_prompt,
existing["id"],
str(png_file),
)
linked_count += 1
self.logger.debug(
f"Linked image {os.path.basename(png_file)} to existing prompt {existing['id']}"
)
except Exception as e:
self.logger.error(
f"Failed to link image {png_file} to existing prompt: {e}"
)
else:
self.logger.debug(
f"Saving new prompt from {os.path.basename(png_file)}"
)
prompt_id = await self._run_in_executor(
self.db.save_prompt,
prompt_text.strip(),
"scanned",
["auto-scanned"],
None,
f"Auto-scanned from {os.path.basename(png_file)}",
prompt_hash,
)
if prompt_id:
added_count += 1
self.logger.info(
f"Successfully saved new prompt with ID {prompt_id} from {os.path.basename(png_file)}"
)
try:
await self._run_in_executor(
self.db.link_image_to_prompt,
prompt_id,
str(png_file),
)
self.logger.debug(
f"Linked image {os.path.basename(png_file)} to new prompt {prompt_id}"
)
except Exception as e:
self.logger.error(
f"Failed to link image {png_file} to new prompt: {e}"
)
else:
self.logger.error(
f"Failed to save prompt from {os.path.basename(png_file)} - no ID returned"
)
except Exception as e:
self.logger.error(
f"Failed to save prompt from {png_file}: {e}"
f"Failed to link {media_file.name} to new prompt: {e}"
)
# Update progress every 10 files
if i % 10 == 0 or i == total_files - 1:
progress = int((i + 1) / total_files * 100)
status = f"Processing file {i + 1}/{total_files}..."
except Exception as e:
self.logger.error(
f"Error processing {media_file.name}: {e}"
)
continue
yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': status, 'processed': processed_count, 'found': found_count})}\n\n"
await asyncio.sleep(0.01)
except Exception as e:
self.logger.error(f"Error processing {png_file}: {e}")
continue
# Progress update after each batch
progress = int(
min(batch_start + len(batch), total_files) / total_files * 100
)
yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Processing file {min(batch_start + len(batch), total_files)}/{total_files}...', 'processed': processed_count, 'found': found_count})}\n\n"
await asyncio.sleep(0)
self.logger.info(
f"Scan completed: processed={processed_count}, found={found_count}, new_prompts_added={added_count}, images_linked_to_existing={linked_count}"
f"Scan completed: processed={processed_count}, found={found_count}, "
f"new_prompts_added={added_count}, images_linked_to_existing={linked_count}"
)
yield f"data: {json.dumps({'type': 'complete', 'processed': processed_count, 'found': found_count, 'added': added_count, 'linked': linked_count})}\n\n"
yield f"data: {json.dumps({'type': 'complete', 'processed': processed_count, 'found': found_count, 'added': added_count, 'linked': linked_count, 'directories': dir_names})}\n\n"
except Exception as e:
self.logger.exception("Scan error")
+102 -69
View File
@@ -68,6 +68,10 @@ class ImageRoutesMixin:
async def clear_thumbnails_route(request):
return await self.clear_thumbnails(request)
@routes.get("/prompt_manager/gallery/subfolders")
async def get_gallery_subfolders_route(request):
return await self.get_gallery_subfolders(request)
async def get_prompt_images(self, request):
"""Get all images for a specific prompt."""
try:
@@ -77,6 +81,9 @@ class ImageRoutesMixin:
# Clean up any NaN values that cause JSON parsing errors (recursive)
cleaned_images = [self._clean_nan_recursive(image) for image in images]
# Add url and thumbnail_url so the frontend can serve them
self._enrich_images(cleaned_images)
# Additional fallback: convert to JSON string and clean NaN values manually
try:
response_data = {"success": True, "images": cleaned_images}
@@ -145,7 +152,10 @@ class ImageRoutesMixin:
return web.json_response({"success": False, "error": str(e)}, status=500)
def _scan_gallery_files_sync(self, output_path):
"""Scan output directory for media files (blocking I/O, run in executor)."""
"""Scan output directory for media files (blocking I/O, run in executor).
Returns list of (path, mtime) tuples sorted by mtime descending.
"""
image_extensions = [".png", ".jpg", ".jpeg", ".webp", ".gif"]
video_extensions = [".mp4", ".webm", ".avi", ".mov", ".mkv", ".m4v", ".wmv"]
media_extensions = image_extensions + video_extensions
@@ -159,62 +169,73 @@ class ImageRoutesMixin:
normalized_path = str(media_path).lower()
if normalized_path not in seen_paths:
seen_paths.add(normalized_path)
all_images.append(media_path)
try:
mtime = media_path.stat().st_mtime
all_images.append((media_path, mtime))
except OSError:
continue
# Sort by modification time (newest first)
all_images.sort(key=lambda x: x.stat().st_mtime, reverse=True)
all_images.sort(key=lambda x: x[1], reverse=True)
return all_images
async def _get_gallery_files(self, output_path):
"""Get gallery files with TTL cache. Invalidated by image monitor."""
"""Get gallery files with per-directory TTL cache."""
now = _time.monotonic()
if (
self._gallery_cache is not None
and (now - self._gallery_cache_time) < self._gallery_cache_ttl
):
return self._gallery_cache
key = str(output_path)
cached = self._gallery_cache.get(key)
if cached and (now - cached[1]) < self._gallery_cache_ttl:
return cached[0]
all_images = await self._run_in_executor(
self._scan_gallery_files_sync, output_path
)
self._gallery_cache = all_images
self._gallery_cache_time = now
return all_images
files = await self._run_in_executor(self._scan_gallery_files_sync, output_path)
self._gallery_cache[key] = (files, now)
return files
async def get_output_images(self, request):
"""Get all images from ComfyUI output folder."""
"""Get all images from ComfyUI output folder(s)."""
try:
from urllib.parse import quote
# Find ComfyUI output directory
output_dir = self._find_comfyui_output_dir()
if not output_dir:
output_dirs = self._get_all_output_dirs()
if not output_dirs:
return web.json_response(
{
"success": False,
"error": "ComfyUI output directory not found",
"error": "No output directories found",
"images": [],
}
},
)
# Get pagination parameters
limit = int(request.query.get("limit", 100))
offset = int(request.query.get("offset", 0))
subfolder = request.query.get("subfolder", "").strip()
# Collect (path, mtime, root) from all directories
all_images = []
for output_path in output_dirs:
dir_images = await self._get_gallery_files(output_path)
for img_path, mtime in dir_images:
all_images.append((img_path, mtime, output_path))
# Sort combined results by mtime (newest first) — no .stat() calls
all_images.sort(key=lambda x: x[1], reverse=True)
# Apply subfolder filter if provided
if subfolder:
filtered = []
for img_path, mtime, root in all_images:
rel_dir = str(img_path.relative_to(root).parent)
if rel_dir == subfolder or rel_dir.startswith(subfolder + os.sep):
filtered.append((img_path, mtime, root))
all_images = filtered
total = len(all_images)
paginated = all_images[offset : offset + limit]
output_path = Path(output_dir)
thumbnails_dir = output_path / "thumbnails"
video_extensions = [".mp4", ".webm", ".avi", ".mov", ".mkv", ".m4v", ".wmv"]
# Use cached file listing (Fix 2.4)
all_images = await self._get_gallery_files(output_path)
# Apply pagination
paginated_images = all_images[offset : offset + limit]
# Format media data in executor (stat calls are blocking)
def _format_page():
images = []
for media_path in paginated_images:
for media_path, mtime, output_path in paginated:
try:
stat = media_path.stat()
rel_path = media_path.relative_to(output_path)
@@ -223,6 +244,7 @@ class ImageRoutesMixin:
media_type = "video" if is_video else "image"
thumbnail_url = None
thumbnails_dir = output_path / "thumbnails"
if thumbnails_dir.exists():
thumbnail_ext = ".jpg" if is_video else extension
rel_path_no_ext = rel_path.with_suffix("")
@@ -237,6 +259,7 @@ class ImageRoutesMixin:
"filename": media_path.name,
"path": str(media_path),
"relative_path": str(rel_path),
"root_dir": str(output_path),
"url": f"/prompt_manager/images/serve/{rel_path.as_posix()}",
"thumbnail_url": thumbnail_url,
"size": stat.st_size,
@@ -257,18 +280,15 @@ class ImageRoutesMixin:
{
"success": True,
"images": images,
"total": len(all_images),
"total": total,
"offset": offset,
"limit": limit,
"has_more": offset + limit < len(all_images),
"has_more": offset + limit < total,
}
)
except Exception as e:
self.logger.error(f"Get output images error: {e}")
return web.json_response(
{"success": False, "error": str(e), "images": []}, status=500
)
self.logger.error(f"Output images error: {e}")
return web.json_response({"success": False, "error": str(e)}, status=500)
async def serve_image(self, request):
"""Serve the actual image file using streamed FileResponse."""
@@ -283,11 +303,13 @@ class ImageRoutesMixin:
image_path = Path(image["image_path"]).resolve()
# Validate path is within the ComfyUI output directory
output_dir = self._find_comfyui_output_dir()
if output_dir:
output_path = Path(output_dir).resolve()
if not image_path.is_relative_to(output_path):
# Validate path is within any configured output directory
output_dirs = self._get_all_output_dirs()
if output_dirs:
allowed = any(
image_path.is_relative_to(d.resolve()) for d in output_dirs
)
if not allowed:
return web.json_response(
{"success": False, "error": "Access denied"}, status=403
)
@@ -314,43 +336,54 @@ class ImageRoutesMixin:
try:
filepath = request.match_info["filepath"]
# Find ComfyUI output directory
output_dir = self._find_comfyui_output_dir()
if not output_dir:
# Search all configured output directories
output_dirs = self._get_all_output_dirs()
if not output_dirs:
return web.json_response(
{"success": False, "error": "ComfyUI output directory not found"},
status=404,
)
# Construct full image path
image_path = Path(output_dir) / filepath
# Try each root directory until the file is found
for output_path in output_dirs:
image_path = (output_path / filepath).resolve()
# Security check: make sure the path is within the output directory
try:
image_path = image_path.resolve()
output_path = Path(output_dir).resolve()
if not image_path.is_relative_to(output_path):
return web.json_response(
{"success": False, "error": "Access denied"}, status=403
)
except Exception:
return web.json_response(
{"success": False, "error": "Invalid file path"}, status=400
)
# Security check: must be within this output directory
if not image_path.is_relative_to(output_path.resolve()):
continue
if not image_path.exists():
return web.json_response(
{"success": False, "error": "Image file not found"}, status=404
)
if image_path.exists():
response = web.FileResponse(image_path)
response.headers["Cache-Control"] = "public, max-age=3600"
return response
response = web.FileResponse(image_path)
response.headers["Cache-Control"] = "public, max-age=3600"
return response
return web.json_response(
{"success": False, "error": "Image file not found"}, status=404
)
except Exception as e:
self.logger.error(f"Serve output image error: {e}")
return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_gallery_subfolders(self, request):
"""Get distinct subfolders from gallery output directories."""
try:
output_dirs = self._get_all_output_dirs()
subfolders = set()
for output_path in output_dirs:
files = await self._get_gallery_files(output_path)
for f, _mtime in files:
rel_dir = str(f.relative_to(output_path).parent)
if rel_dir and rel_dir != ".":
subfolders.add(rel_dir)
return web.json_response(
{"success": True, "subfolders": sorted(subfolders)}
)
except Exception as e:
self.logger.error(f"Subfolders error: {e}")
return web.json_response({"success": False, "error": str(e)}, status=500)
async def generate_thumbnails(self, request):
"""Generate thumbnails for all images and videos in the ComfyUI output directory."""
try:
+21
View File
@@ -113,6 +113,10 @@ class PromptRoutesMixin:
async def bulk_set_category_route(request):
return await self.bulk_set_category(request)
@routes.get("/prompt_manager/subfolders")
async def get_subfolders_route(request):
return await self.get_subfolders(request)
# Export functionality
@routes.get("/prompt_manager/export")
async def export_prompts_route(request):
@@ -127,6 +131,8 @@ class PromptRoutesMixin:
min_rating = request.query.get("min_rating", 0)
limit = int(request.query.get("limit", 50))
folder = request.query.get("folder", "").strip() or None
tags = None
if tags_str:
tags = [tag.strip() for tag in tags_str.split(",") if tag.strip()]
@@ -143,6 +149,7 @@ class PromptRoutesMixin:
tags=tags,
rating_min=min_rating,
limit=limit,
folder=folder,
)
self._enrich_prompt_images(results)
@@ -157,6 +164,20 @@ class PromptRoutesMixin:
status=500,
)
async def get_subfolders(self, request):
"""Get distinct subfolder values derived from generated image paths."""
try:
from ..config import GalleryConfig
root_dirs = list(GalleryConfig.MONITORING_DIRECTORIES) or None
subfolders = await self._run_in_executor(
self.db.get_prompt_subfolders, root_dirs
)
return web.json_response({"success": True, "subfolders": subfolders})
except Exception as e:
self.logger.error(f"Subfolders error: {e}")
return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_recent_prompts(self, request):
"""Retrieve recently created prompts with pagination support."""
try:
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "promptmanager"
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
version = "3.1.6"
version = "3.2.0"
license = {file = "LICENSE"}
dependencies = ["# Core dependencies for PromptManager", "# Note: Most dependencies are already included with ComfyUI", "# Already included with Python standard library:", "# - sqlite3", "# - hashlib", "# - json", "# - datetime", "# - os", "# - typing", "# - threading", "# - uuid", "# Required for gallery functionality:", "watchdog>=2.1.0 # For file system monitoring", "Pillow>=8.0.0 # For image metadata extraction (usually included with ComfyUI)", "# Optional dependencies for enhanced search functionality:", "# fuzzywuzzy[speedup]>=0.18.0 # For fuzzy string matching (optional)", "# sqlalchemy>=1.4.0 # For advanced ORM features (optional)", "# Development dependencies (optional):", "# pytest>=6.0.0 # For running tests", "# black>=22.0.0 # For code formatting", "# flake8>=4.0.0 # For linting", "# mypy>=0.910 # For type checking"]
+66 -5
View File
@@ -78,7 +78,7 @@
<div class="px-4 py-3">
<div class="max-w-7xl mx-auto">
<div class="bg-pm-surface rounded-pm-lg p-4 border border-pm">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3 mb-3">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-7 gap-3 mb-3">
<div class="lg:col-span-2">
<label class="block text-xs font-medium text-pm-secondary mb-1 uppercase tracking-wide">Search Text</label>
<input type="text" id="searchText" placeholder="Search prompt content..."
@@ -91,6 +91,13 @@
<option value="">All Categories</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-pm-secondary mb-1 uppercase tracking-wide">Folder</label>
<select id="searchFolder"
class="w-full px-2.5 py-1.5 bg-pm-input border border-pm rounded-pm-sm text-pm text-[13px] focus:outline-none">
<option value="">All Folders</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-pm-secondary mb-1 uppercase tracking-wide">Tags</label>
<input type="text" id="searchTags" placeholder="tag1, tag2..."
@@ -353,10 +360,18 @@
<h4 class="text-xs font-semibold text-pm-secondary mb-3 uppercase tracking-wide">Gallery Settings</h4>
<div class="mb-3">
<label class="block text-xs font-medium text-pm-secondary mb-1">Image Scan Directory</label>
<input type="text" id="galleryRootPath" placeholder="Leave empty for auto-detect"
class="w-full px-2.5 py-1.5 bg-pm-input border border-pm rounded-pm-sm text-pm text-[13px] focus:outline-none">
<p class="text-xs text-pm-muted mt-1">Custom path to scan for images. Leave empty to use ComfyUI's default output directory.</p>
<label class="block text-xs font-medium text-pm-secondary mb-1">Image Scan Directories</label>
<div id="scanPathsList" class="space-y-2">
<!-- Populated dynamically by JS -->
</div>
<button id="addScanPathBtn" type="button"
class="mt-2 flex items-center gap-1 text-xs text-pm-accent hover:text-pm-accent-hover">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Add Directory
</button>
<p class="text-xs text-pm-muted mt-1">Directories to scan for images. Leave empty to use ComfyUI default output directory.</p>
</div>
<div class="mb-3">
@@ -1224,6 +1239,52 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr
</div>
</div>
<!-- Update Notice Modal -->
<div id="updateNoticeModal" class="fixed inset-0 bg-black/60 backdrop-blur-[2px] hidden items-center justify-center z-50">
<div class="bg-pm-surface rounded-pm-lg p-4 max-w-lg w-full mx-4 border border-pm shadow-pm">
<div class="flex items-start gap-3 mb-3">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-pm-warning-tint flex items-center justify-center">
<svg class="w-4 h-4 text-pm-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<h3 class="text-sm font-semibold text-pm">Image Rescan Required</h3>
<p class="text-xs text-pm-secondary mt-0.5">Multi-directory support update</p>
</div>
</div>
<div class="bg-pm-warning-tint border border-pm-warning/40 rounded-pm-md p-3 mb-3">
<p class="text-pm text-xs leading-relaxed">
This update changes how images are stored and organized. To ensure your gallery works correctly
with the new multi-directory support, you need to <strong>rescan your images</strong>.
</p>
<ul class="text-pm text-xs mt-2 space-y-1 list-disc list-inside text-pm-secondary">
<li>Folder information will be added to existing image records</li>
<li>Your prompts and tags will not be affected</li>
<li>The scan may take a few minutes depending on your library size</li>
</ul>
</div>
<div class="flex items-center justify-between mt-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="dismissUpdateNotice" class="rounded border-pm bg-pm-input text-pm-accent focus:ring-pm-accent w-3.5 h-3.5">
<span class="text-xs text-pm-muted">Don't show this again</span>
</label>
<div class="flex gap-2">
<button id="updateNoticeDismiss"
class="px-3 py-1.5 bg-pm-surface border border-pm hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
Dismiss
</button>
<button id="updateNoticeScan"
class="px-3 py-1.5 bg-pm-accent hover:bg-pm-accent-hover text-pm-accent-fg text-xs font-medium rounded-pm-sm transition-colors">
Scan Now
</button>
</div>
</div>
</div>
</div>
<!-- Auto Tag Download Modal -->
<div id="autoTagDownloadModal" class="fixed inset-0 bg-black/60 backdrop-blur-[2px] hidden items-center justify-center z-50">
<div class="bg-pm-surface rounded-pm-lg p-4 max-w-md w-full mx-4 border border-pm shadow-pm">
+120 -6
View File
@@ -8,6 +8,7 @@
};
this.categories = [];
this.tags = [];
this.subfolders = [];
this.imageViewMode = 'fit'; // 'fit' or 'full'
this.naturalImageSize = { width: 0, height: 0 };
@@ -28,6 +29,7 @@
this.bindEvents();
this.initRouter();
this.loadInitialData();
this.checkUpdateNotice();
}
initRouter() {
@@ -108,7 +110,7 @@
this.bindModalEvents();
// Auto-search on filter changes
["searchCategory"].forEach((id) => {
["searchCategory", "searchFolder"].forEach((id) => {
document.getElementById(id).addEventListener("change", () => this.search());
});
@@ -121,6 +123,7 @@
document.getElementById("saveSettings").addEventListener("click", () => this.saveSettings());
document.getElementById("cancelSettings").addEventListener("click", () => this.hideModal("settingsModal"));
document.getElementById("refreshMonitoringStatus").addEventListener("click", () => this.updateMonitoringStatus());
document.getElementById("addScanPathBtn").addEventListener("click", () => this.addScanPath());
// Bulk tag modal
document.getElementById("confirmBulkTag").addEventListener("click", () => this.confirmBulkTag());
@@ -212,6 +215,7 @@
await Promise.all([
this.loadStatistics(),
this.loadCategories(),
this.loadSubfolders(),
this.loadTags(),
this.loadRecentPrompts(),
this.loadSettings(),
@@ -230,8 +234,12 @@
if (data.success && data.settings) {
this.settings.resultTimeout = data.settings.result_timeout || 5;
this.settings.webuiDisplayMode = data.settings.webui_display_mode || 'popup';
this.settings.galleryRootPath = data.settings.gallery_root_path || '';
this.settings.monitoredDirectories = data.settings.monitored_directories || [];
this.settings.galleryRootPaths = data.settings.gallery_root_paths || [];
// Backward compat: if server only returned old field
if (!this.settings.galleryRootPaths.length && data.settings.gallery_root_path) {
this.settings.galleryRootPaths = [data.settings.gallery_root_path];
}
}
}
} catch (error) {
@@ -310,6 +318,38 @@
});
}
async loadSubfolders() {
try {
const response = await fetch("/prompt_manager/subfolders");
if (response.ok) {
const data = await response.json();
if (data.success) {
this.subfolders = data.subfolders;
this.populateFolderDropdown();
}
}
} catch (error) {
console.error("Subfolders error:", error);
}
}
populateFolderDropdown() {
const select = document.getElementById("searchFolder");
const current = select.value;
select.textContent = '';
const defaultOpt = document.createElement("option");
defaultOpt.value = "";
defaultOpt.textContent = "All Folders";
select.appendChild(defaultOpt);
this.subfolders.forEach((folder) => {
const option = document.createElement("option");
option.value = folder;
option.textContent = folder;
select.appendChild(option);
});
select.value = current;
}
async loadRecentPrompts(page = 1) {
try {
this.pagination.currentPage = page;
@@ -377,6 +417,8 @@
if (searchText) params.append("text", searchText);
if (category) params.append("category", category);
if (tags) params.append("tags", tags);
const folder = document.getElementById("searchFolder").value;
if (folder) params.append("folder", folder);
params.append("limit", "100");
const response = await fetch(`/prompt_manager/search?${params}`);
@@ -687,10 +729,31 @@
document.body.style.overflow = "";
}
checkUpdateNotice() {
const NOTICE_VERSION = "3.2.0";
const dismissed = localStorage.getItem("pm_update_notice_dismissed");
if (dismissed === NOTICE_VERSION) return;
this.showModal("updateNoticeModal");
document.getElementById("updateNoticeDismiss").addEventListener("click", () => {
if (document.getElementById("dismissUpdateNotice").checked) {
localStorage.setItem("pm_update_notice_dismissed", NOTICE_VERSION);
}
this.hideModal("updateNoticeModal");
});
document.getElementById("updateNoticeScan").addEventListener("click", () => {
localStorage.setItem("pm_update_notice_dismissed", NOTICE_VERSION);
this.hideModal("updateNoticeModal");
this.showScanModal();
});
}
showSettingsModal() {
document.getElementById("resultTimeout").value = this.settings.resultTimeout;
document.getElementById("webuiDisplayMode").value = this.settings.webuiDisplayMode;
document.getElementById("galleryRootPath").value = this.settings.galleryRootPath || '';
this.renderScanPaths();
this.updateMonitoringStatus();
this.showModal("settingsModal");
}
@@ -716,11 +779,11 @@
async saveSettings() {
const timeout = parseInt(document.getElementById("resultTimeout").value);
const displayMode = document.getElementById("webuiDisplayMode").value;
const galleryPath = document.getElementById("galleryRootPath").value.trim();
const galleryPaths = this._collectScanPaths().filter(p => p !== '');
this.settings.resultTimeout = timeout;
this.settings.webuiDisplayMode = displayMode;
this.settings.galleryRootPath = galleryPath;
this.settings.galleryRootPaths = galleryPaths;
try {
const response = await fetch("/prompt_manager/settings", {
@@ -729,7 +792,7 @@
body: JSON.stringify({
result_timeout: timeout,
webui_display_mode: displayMode,
gallery_root_path: galleryPath
gallery_root_paths: galleryPaths
}),
});
@@ -749,6 +812,57 @@
}
}
renderScanPaths() {
const container = document.getElementById("scanPathsList");
const paths = this.settings.galleryRootPaths || [];
container.textContent = ''; // Clear safely
const toRender = paths.length > 0 ? paths : [''];
toRender.forEach((p, i) => {
const row = document.createElement('div');
row.className = 'flex gap-2 items-center';
row.dataset.pathIndex = i;
const input = document.createElement('input');
input.type = 'text';
input.value = p;
input.placeholder = 'Leave empty for auto-detect';
input.className = 'flex-1 px-2.5 py-1.5 bg-pm-input border border-pm rounded-pm-sm text-pm text-[13px] focus:outline-none scan-path-input';
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'text-pm-muted hover:text-red-400 p-1 text-lg leading-none';
removeBtn.title = 'Remove';
removeBtn.textContent = '\u00d7'; // multiplication sign as X
removeBtn.addEventListener('click', () => this.removeScanPath(i));
row.appendChild(input);
row.appendChild(removeBtn);
container.appendChild(row);
});
}
addScanPath() {
const paths = this._collectScanPaths();
paths.push('');
this.settings.galleryRootPaths = paths;
this.renderScanPaths();
const inputs = document.querySelectorAll('.scan-path-input');
if (inputs.length) inputs[inputs.length - 1].focus();
}
removeScanPath(index) {
const paths = this._collectScanPaths();
paths.splice(index, 1);
this.settings.galleryRootPaths = paths;
this.renderScanPaths();
}
_collectScanPaths() {
return Array.from(document.querySelectorAll('.scan-path-input'))
.map(input => input.value.trim());
}
toggleSelectAll(checked) {
document.querySelectorAll(".prompt-checkbox").forEach((checkbox) => {
checkbox.checked = checked;