mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-26 04:41:16 -03:00
Compare commits
8 Commits
a5c861646c
...
v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f4edbeb9d | ||
|
|
a256558a0e | ||
|
|
818b9113f0 | ||
|
|
6a4fd020dc | ||
|
|
7a23040452 | ||
|
|
138024aefe | ||
|
|
a19ddc14f6 | ||
|
|
7001ced694 |
@@ -33,6 +33,7 @@ from .utils.example_images_migration import ExampleImagesMigration
|
|||||||
from .services.websocket_manager import ws_manager
|
from .services.websocket_manager import ws_manager
|
||||||
from .services.example_images_cleanup_service import ExampleImagesCleanupService
|
from .services.example_images_cleanup_service import ExampleImagesCleanupService
|
||||||
from .middleware.csp_middleware import relax_csp_for_remote_media
|
from .middleware.csp_middleware import relax_csp_for_remote_media
|
||||||
|
from .middleware.error_middleware import api_json_error
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -76,6 +77,11 @@ class LoraManager:
|
|||||||
"""Initialize and register all routes using the new refactored architecture"""
|
"""Initialize and register all routes using the new refactored architecture"""
|
||||||
app = PromptServer.instance.app
|
app = PromptServer.instance.app
|
||||||
|
|
||||||
|
# Register JSON error middleware for /api/* routes as the outermost
|
||||||
|
# middleware so it catches errors from all other middlewares.
|
||||||
|
if api_json_error not in app.middlewares:
|
||||||
|
app.middlewares.insert(0, api_json_error)
|
||||||
|
|
||||||
if relax_csp_for_remote_media not in app.middlewares:
|
if relax_csp_for_remote_media not in app.middlewares:
|
||||||
# Ensure CSP relaxer executes after ComfyUI's block_external_middleware so it can
|
# Ensure CSP relaxer executes after ComfyUI's block_external_middleware so it can
|
||||||
# see and extend the restrictive header instead of being overwritten by it.
|
# see and extend the restrictive header instead of being overwritten by it.
|
||||||
|
|||||||
71
py/middleware/error_middleware.py
Normal file
71
py/middleware/error_middleware.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""JSON error middleware for API routes.
|
||||||
|
|
||||||
|
Ensures all responses to /api/* requests return valid JSON that the
|
||||||
|
browser-extension frontend can JSON.parse() without crashing, even when
|
||||||
|
the route does not exist (404) or the handler raises an exception (500).
|
||||||
|
|
||||||
|
Extension consumers call response.json() unconditionally — an HTML error
|
||||||
|
page causes ``SyntaxError: unexpected end of data`` that leaks into the
|
||||||
|
popup UI as a toast notification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def api_json_error(
|
||||||
|
request: web.Request,
|
||||||
|
handler: Callable[[web.Request], Awaitable[web.Response]],
|
||||||
|
) -> web.Response:
|
||||||
|
"""Return JSON ``{"success": false, "error": "..."}`` for API errors.
|
||||||
|
|
||||||
|
Only intercepts paths starting with ``/api/`` — all other routes
|
||||||
|
(frontend pages, static files, WebSocket upgrades) pass through
|
||||||
|
unchanged.
|
||||||
|
"""
|
||||||
|
if not request.path.startswith("/api/"):
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await handler(request)
|
||||||
|
return response
|
||||||
|
except web.HTTPException as exc:
|
||||||
|
# Let redirects (301, 302, 307, 308) propagate — they are not errors.
|
||||||
|
if exc.status < 400:
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"API %s %s returned HTTP %d: %s",
|
||||||
|
request.method,
|
||||||
|
request.path,
|
||||||
|
exc.status,
|
||||||
|
exc.reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": f"{exc.status}: {exc.reason}"},
|
||||||
|
status=exc.status,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"API %s %s raised unhandled exception: %s",
|
||||||
|
request.method,
|
||||||
|
request.path,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f"500: Internal Server Error ({type(exc).__name__})",
|
||||||
|
},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
@@ -11,7 +11,7 @@ from ..metadata_collector.metadata_processor import MetadataProcessor
|
|||||||
from ..metadata_collector import get_metadata
|
from ..metadata_collector import get_metadata
|
||||||
from ..utils.constants import CARD_PREVIEW_WIDTH
|
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||||
from ..utils.exif_utils import ExifUtils
|
from ..utils.exif_utils import ExifUtils
|
||||||
from ..utils.utils import calculate_recipe_fingerprint
|
from ..utils.utils import calculate_recipe_fingerprint, sanitize_folder_name
|
||||||
from PIL import Image, PngImagePlugin
|
from PIL import Image, PngImagePlugin
|
||||||
import piexif
|
import piexif
|
||||||
import logging
|
import logging
|
||||||
@@ -309,12 +309,14 @@ class SaveImageLM:
|
|||||||
filename = filename.replace(segment, str(h))
|
filename = filename.replace(segment, str(h))
|
||||||
elif key == "pprompt" and "prompt" in metadata_dict:
|
elif key == "pprompt" and "prompt" in metadata_dict:
|
||||||
prompt = metadata_dict.get("prompt", "").replace("\n", " ")
|
prompt = metadata_dict.get("prompt", "").replace("\n", " ")
|
||||||
|
prompt = sanitize_folder_name(prompt)
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
length = int(parts[1])
|
length = int(parts[1])
|
||||||
prompt = prompt[:length]
|
prompt = prompt[:length]
|
||||||
filename = filename.replace(segment, prompt.strip())
|
filename = filename.replace(segment, prompt.strip())
|
||||||
elif key == "nprompt" and "negative_prompt" in metadata_dict:
|
elif key == "nprompt" and "negative_prompt" in metadata_dict:
|
||||||
prompt = metadata_dict.get("negative_prompt", "").replace("\n", " ")
|
prompt = metadata_dict.get("negative_prompt", "").replace("\n", " ")
|
||||||
|
prompt = sanitize_folder_name(prompt)
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
length = int(parts[1])
|
length = int(parts[1])
|
||||||
prompt = prompt[:length]
|
prompt = prompt[:length]
|
||||||
@@ -328,6 +330,7 @@ class SaveImageLM:
|
|||||||
model = "model_unavailable"
|
model = "model_unavailable"
|
||||||
else:
|
else:
|
||||||
model = os.path.splitext(os.path.basename(model_value))[0]
|
model = os.path.splitext(os.path.basename(model_value))[0]
|
||||||
|
model = sanitize_folder_name(model)
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
length = int(parts[1])
|
length = int(parts[1])
|
||||||
model = model[:length]
|
model = model[:length]
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from ...config import config as global_config
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_CHUNK_SIZE = 256 * 1024 # 256 KB
|
_CHUNK_SIZE = 1024 * 1024 # 1 MB — balance between streaming iteration overhead and per-chunk memory
|
||||||
|
|
||||||
# Video file extensions that bypass native sendfile on Windows
|
# Video file extensions that bypass native sendfile on Windows
|
||||||
# to avoid IOCP/ProactorEventLoop crashes during client disconnect.
|
# to avoid IOCP/ProactorEventLoop crashes during client disconnect.
|
||||||
@@ -55,16 +55,19 @@ class PreviewHandler:
|
|||||||
logger.debug("Preview file not found at %s", str(resolved))
|
logger.debug("Preview file not found at %s", str(resolved))
|
||||||
raise web.HTTPNotFound(text="Preview file not found")
|
raise web.HTTPNotFound(text="Preview file not found")
|
||||||
|
|
||||||
# Video files: stream manually to avoid Windows native sendfile crash.
|
# aiohttp's FileResponse handles range requests, content headers, and
|
||||||
# aiohttp's FileResponse uses _sendfile_native on Windows (IOCP-based),
|
# uses kernel sendfile (zero-copy DMA) on Linux/macOS. On Windows it
|
||||||
# which breaks when the client disconnects mid-transfer — this happens
|
# uses IOCP-based _sendfile_native which can crash when the client
|
||||||
# constantly when users scroll through a gallery of animated previews.
|
# disconnects mid-transfer during fast scrolling. The _stream_file()
|
||||||
suffix = resolved.suffix.lower()
|
# fallback is kept for a future compat toggle.
|
||||||
if suffix in _VIDEO_EXTENSIONS:
|
#
|
||||||
return await self._stream_file(request, resolved)
|
# Set explicit Cache-Control so the browser can cache video (and image)
|
||||||
|
# previews across VirtualScroller recycling cycles. Without this,
|
||||||
# aiohttp's FileResponse handles range requests and content headers for us.
|
# Chrome does not cache 206 Partial Content responses for <video>
|
||||||
return web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
|
# 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 _stream_file(
|
async def _stream_file(
|
||||||
self, request: web.Request, path: Path
|
self, request: web.Request, path: Path
|
||||||
@@ -83,6 +86,10 @@ class PreviewHandler:
|
|||||||
resp.content_type = content_type
|
resp.content_type = content_type
|
||||||
resp.content_length = file_size
|
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)
|
await resp.prepare(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ class DownloadQueueService:
|
|||||||
async with cls._class_lock:
|
async with cls._class_lock:
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
cls._instance = cls()
|
cls._instance = cls()
|
||||||
|
await cls._instance.deduplicate()
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def __init__(self, db_path: Optional[str] = None) -> None:
|
def __init__(self, db_path: Optional[str] = None) -> None:
|
||||||
@@ -608,7 +609,9 @@ class DownloadQueueService:
|
|||||||
|
|
||||||
Looks up the history record by its primary key. If the status is
|
Looks up the history record by its primary key. If the status is
|
||||||
``failed`` or ``canceled`` a new queue entry is created with the
|
``failed`` or ``canceled`` a new queue entry is created with the
|
||||||
same model metadata and a fresh download id.
|
same model metadata and a fresh download id, and the original
|
||||||
|
history entry is **deleted** to prevent exponential growth when
|
||||||
|
the retried item is later canceled or fails again and re-retried.
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
@@ -645,6 +648,10 @@ class DownloadQueueService:
|
|||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM download_history WHERE id = ?",
|
||||||
|
(item_id,),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
queued = conn.execute(
|
queued = conn.execute(
|
||||||
"SELECT * FROM download_queue WHERE download_id = ?",
|
"SELECT * FROM download_queue WHERE download_id = ?",
|
||||||
@@ -656,6 +663,9 @@ class DownloadQueueService:
|
|||||||
async def retry_all_failed(self) -> int:
|
async def retry_all_failed(self) -> int:
|
||||||
"""Re-queue all failed and canceled downloads from history.
|
"""Re-queue all failed and canceled downloads from history.
|
||||||
|
|
||||||
|
Each history entry is **deleted** after being re-queued so that
|
||||||
|
repeated retry-all calls do not cause exponential growth.
|
||||||
|
|
||||||
Returns the number of items that were re-queued.
|
Returns the number of items that were re-queued.
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
@@ -691,6 +701,10 @@ class DownloadQueueService:
|
|||||||
now,
|
now,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM download_history WHERE id = ?",
|
||||||
|
(row["id"],),
|
||||||
|
)
|
||||||
count += 1
|
count += 1
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
@@ -732,3 +746,126 @@ class DownloadQueueService:
|
|||||||
"failed": history_stats.get("failed", 0),
|
"failed": history_stats.get("failed", 0),
|
||||||
"canceled": history_stats.get("canceled", 0),
|
"canceled": history_stats.get("canceled", 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Deduplication (one-time cleanup for bug #980)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def deduplicate(self) -> dict[str, int]:
|
||||||
|
"""Remove duplicate entries caused by the retry-amplification bug.
|
||||||
|
|
||||||
|
The bug (issue #980) caused the same download to appear N times in
|
||||||
|
both the queue and history tables when ``retry_all_failed`` was
|
||||||
|
called repeatedly without deleting the original history rows.
|
||||||
|
|
||||||
|
This method is called **once** when the singleton is first created.
|
||||||
|
It is idempotent — after the first run there will be no duplicates
|
||||||
|
to remove, so subsequent calls are a no-op.
|
||||||
|
|
||||||
|
Returns a dict with the count of removed rows per table.
|
||||||
|
"""
|
||||||
|
result: dict[str, int] = {
|
||||||
|
"removed_history": 0,
|
||||||
|
"removed_queue": 0,
|
||||||
|
"removed_orphan_queue": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
conn = self._get_conn()
|
||||||
|
|
||||||
|
# 1. History: for each (model_id, model_version_id, status) triplet
|
||||||
|
# keep only the row with the highest id (most recently inserted).
|
||||||
|
conn.execute("""
|
||||||
|
DELETE FROM download_history
|
||||||
|
WHERE id NOT IN (
|
||||||
|
SELECT MAX(id)
|
||||||
|
FROM download_history
|
||||||
|
GROUP BY model_id, model_version_id, status
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
result["removed_history"] = conn.execute(
|
||||||
|
"SELECT changes()"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
# 2. Cross-status dedup: for each (model_id, model_version_id),
|
||||||
|
# keep only the entry with the highest-priority terminal status.
|
||||||
|
# Priority: completed (3) > failed (2) > canceled (1).
|
||||||
|
# This prevents the same model version from having both a
|
||||||
|
# 'failed' and a 'canceled' entry (or a 'completed' alongside
|
||||||
|
# either) after the bug-created duplicates are removed.
|
||||||
|
conn.execute("""
|
||||||
|
DELETE FROM download_history
|
||||||
|
WHERE id NOT IN (
|
||||||
|
SELECT dh.id
|
||||||
|
FROM download_history dh
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT model_id, model_version_id,
|
||||||
|
MAX(CASE status
|
||||||
|
WHEN 'completed' THEN 3
|
||||||
|
WHEN 'failed' THEN 2
|
||||||
|
WHEN 'canceled' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END) AS best_prio
|
||||||
|
FROM download_history
|
||||||
|
GROUP BY model_id, model_version_id
|
||||||
|
) best
|
||||||
|
ON dh.model_id = best.model_id
|
||||||
|
AND dh.model_version_id = best.model_version_id
|
||||||
|
AND CASE dh.status
|
||||||
|
WHEN 'completed' THEN 3
|
||||||
|
WHEN 'failed' THEN 2
|
||||||
|
WHEN 'canceled' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END = best.best_prio
|
||||||
|
GROUP BY dh.model_id, dh.model_version_id
|
||||||
|
HAVING dh.id = MAX(dh.id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
result["removed_history"] += conn.execute(
|
||||||
|
"SELECT changes()"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
# 3. Queue: for each (model_id, model_version_id) keep only the
|
||||||
|
# row with the latest added_at (most recently enqueued).
|
||||||
|
conn.execute("""
|
||||||
|
DELETE FROM download_queue
|
||||||
|
WHERE rowid NOT IN (
|
||||||
|
SELECT MAX(rowid)
|
||||||
|
FROM download_queue
|
||||||
|
WHERE status IN ('queued', 'downloading', 'paused', 'waiting')
|
||||||
|
GROUP BY model_id, model_version_id
|
||||||
|
)
|
||||||
|
AND status IN ('queued', 'downloading', 'paused', 'waiting')
|
||||||
|
""")
|
||||||
|
result["removed_queue"] = conn.execute(
|
||||||
|
"SELECT changes()"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
# 4. Remove orphaned queue entries — items that were re-queued
|
||||||
|
# (source='retry') but whose model version already has a
|
||||||
|
# terminal history entry. These are artifacts of the buggy
|
||||||
|
# retry cycle that were never cleaned up.
|
||||||
|
conn.execute("""
|
||||||
|
DELETE FROM download_queue
|
||||||
|
WHERE source = 'retry'
|
||||||
|
AND (model_id, model_version_id) IN (
|
||||||
|
SELECT model_id, model_version_id
|
||||||
|
FROM download_history
|
||||||
|
WHERE status IN ('failed', 'canceled')
|
||||||
|
)
|
||||||
|
AND status IN ('queued', 'waiting')
|
||||||
|
""")
|
||||||
|
result["removed_orphan_queue"] = conn.execute(
|
||||||
|
"SELECT changes()"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Deduplicate: removed %s history rows, %s queue rows, "
|
||||||
|
"%s orphaned queue rows",
|
||||||
|
result["removed_history"],
|
||||||
|
result["removed_queue"],
|
||||||
|
result["removed_orphan_queue"],
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -81,7 +81,11 @@ class _RateLimitRetryHelper:
|
|||||||
|
|
||||||
def _calculate_delay(self, retry_after: Optional[float], attempt: int) -> float:
|
def _calculate_delay(self, retry_after: Optional[float], attempt: int) -> float:
|
||||||
if retry_after is not None:
|
if retry_after is not None:
|
||||||
return min(self._max_delay, max(0.0, retry_after))
|
# Cap at 1800s (30 min) as a safety ceiling. The old 30s cap was
|
||||||
|
# too low — CivArchive can return retry_after ~1500s, causing all
|
||||||
|
# retries to fail. A generous ceiling protects against pathological
|
||||||
|
# server values while still respecting the server's guidance.
|
||||||
|
return min(1800.0, max(0.0, retry_after))
|
||||||
|
|
||||||
base_delay = self._base_delay * (2 ** max(0, attempt - 1))
|
base_delay = self._base_delay * (2 ** max(0, attempt - 1))
|
||||||
jitter_span = base_delay * self._jitter_ratio
|
jitter_span = base_delay * self._jitter_ratio
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.1.0"
|
version = "1.1.1"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
from py.middleware.cache_middleware import cache_control
|
from py.middleware.cache_middleware import cache_control
|
||||||
|
from py.middleware.error_middleware import api_json_error
|
||||||
from py.utils.settings_paths import ensure_settings_file
|
from py.utils.settings_paths import ensure_settings_file
|
||||||
|
|
||||||
# Set environment variable to indicate standalone mode
|
# Set environment variable to indicate standalone mode
|
||||||
@@ -157,7 +158,7 @@ class StandaloneServer:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.app = web.Application(
|
self.app = web.Application(
|
||||||
logger=logger,
|
logger=logger,
|
||||||
middlewares=[cache_control],
|
middlewares=[api_json_error, cache_control],
|
||||||
client_max_size=256 * 1024 * 1024,
|
client_max_size=256 * 1024 * 1024,
|
||||||
handler_args={
|
handler_args={
|
||||||
"max_field_size": HEADER_SIZE_LIMIT,
|
"max_field_size": HEADER_SIZE_LIMIT,
|
||||||
|
|||||||
Reference in New Issue
Block a user