mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-09 15:30:16 -03:00
Compare commits
5 Commits
53825500db
...
99e65cccd8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e65cccd8 | ||
|
|
3bdacb8f46 | ||
|
|
b4f9c224d3 | ||
|
|
5ec0399c81 | ||
|
|
b464fdc333 |
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "Zu Nightly-Kanal wechseln",
|
||||
"nightlyMessage": "Der Wechsel zu Nightly initialisiert ein Git-Repository und verfolgt die neuesten Commits des main-Branches. Updates sind häufiger, können aber instabil sein. Sie können jederzeit zu Release zurückwechseln.",
|
||||
"releaseTitle": "Zu Release-Kanal wechseln",
|
||||
"releaseMessage": "Der Wechsel zu Release entfernt das Git-Repository und installiert die neueste stabile Version. Zukünftige Updates verwenden nur stabile Versionen.",
|
||||
"releaseMessage": "Der Wechsel zu Release checkt den neuesten stabilen Versions-Tag aus. Sie können jederzeit zu Nightly zurückwechseln.",
|
||||
"switching": "Wechsle zu {channel}-Kanal...",
|
||||
"completed": "Erfolgreich zu {channel}-Kanal gewechselt",
|
||||
"failed": "Kanalwechsel fehlgeschlagen"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "Switch to Nightly Channel",
|
||||
"nightlyMessage": "Switching to Nightly will initialize a Git repository and track the latest main branch commits. Updates will be more frequent but may be unstable. You can switch back to Release at any time.",
|
||||
"releaseTitle": "Switch to Release Channel",
|
||||
"releaseMessage": "Switching to Release will remove the Git repository and install the latest stable release. Future updates will use stable releases only.",
|
||||
"releaseMessage": "Switching to Release will checkout the latest stable release tag. You can switch back to Nightly at any time.",
|
||||
"switching": "Switching to {channel} channel...",
|
||||
"completed": "Successfully switched to {channel} channel",
|
||||
"failed": "Failed to switch channel"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "Cambiar a canal Nightly",
|
||||
"nightlyMessage": "Cambiar a Nightly inicializara un repositorio Git y seguira los ultimos commits de la rama main. Las actualizaciones son mas frecuentes pero pueden ser inestables. Puede volver a Release en cualquier momento.",
|
||||
"releaseTitle": "Cambiar a canal Release",
|
||||
"releaseMessage": "Cambiar a Release eliminara el repositorio Git e instalara la ultima version estable. Las futuras actualizaciones usaran solo versiones estables.",
|
||||
"releaseMessage": "Cambiar a Release hara checkout de la ultima etiqueta de version estable. Puede volver a Nightly en cualquier momento.",
|
||||
"switching": "Cambiando a canal {channel}...",
|
||||
"completed": "Cambio a canal {channel} exitoso",
|
||||
"failed": "Error al cambiar de canal"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "Passer au canal Nightly",
|
||||
"nightlyMessage": "Passer a Nightly initialisera un depot Git et suivra les derniers commits de la branche main. Les mises a jour sont plus frequentes mais peuvent etre instables. Vous pouvez revenir a Release a tout moment.",
|
||||
"releaseTitle": "Passer au canal Release",
|
||||
"releaseMessage": "Passer a Release supprimera le depot Git et installera la derniere version stable. Les futures mises a jour utiliseront uniquement des versions stables.",
|
||||
"releaseMessage": "Passer a Release passera au dernier tag de version stable. Vous pouvez revenir a Nightly a tout moment.",
|
||||
"switching": "Passage au canal {channel}...",
|
||||
"completed": "Basculement vers le canal {channel} reussi",
|
||||
"failed": "Echec du changement de canal"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "מעבר לערוץ Nightly",
|
||||
"nightlyMessage": "מעבר ל-Nightly יאתחל מאגר Git ויעקוב אחר הקומיטים האחרונים בענף main. העדכונים תכופים יותר אך עשויים להיות לא יציבים. ניתן לחזור ל-Release בכל עת.",
|
||||
"releaseTitle": "מעבר לערוץ Release",
|
||||
"releaseMessage": "מעבר ל-Release יסיר את מאגר ה-Git ויתקין את הגרסה היציבה האחרונה. עדכונים עתידיים ישתמשו בגרסאות יציבות בלבד.",
|
||||
"releaseMessage": "מעבר ל-Release יעבור לתגית הגרסה היציבה האחרונה. ניתן לחזור ל-Nightly בכל עת.",
|
||||
"switching": "מעבר לערוץ {channel}...",
|
||||
"completed": "המעבר לערוץ {channel} הושלם",
|
||||
"failed": "החלפת ערוץ נכשלה"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "ナイトリーチャンネルに切り替え",
|
||||
"nightlyMessage": "ナイトリーに切り替えると、Gitリポジトリが初期化され、mainブランチの最新コミットを追跡します。更新頻度は高くなりますが、不安定な場合があります。いつでもリリース版に戻せます。",
|
||||
"releaseTitle": "リリースチャンネルに切り替え",
|
||||
"releaseMessage": "リリースに切り替えると、Gitリポジトリが削除され、最新の安定版がインストールされます。以降の更新は安定版のみが使用されます。",
|
||||
"releaseMessage": "リリースに切り替えると、最新の安定版タグにチェックアウトされます。いつでもNightlyに戻せます。",
|
||||
"switching": "{channel} チャンネルに切り替え中...",
|
||||
"completed": "{channel} チャンネルに切り替えました",
|
||||
"failed": "チャンネルの切り替えに失敗しました"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "나이틀리 채널로 전환",
|
||||
"nightlyMessage": "나이틀리로 전환하면 Git 저장소가 초기화되고 main 브랜치의 최신 커밋을 추적합니다. 업데이트 빈도는 높지만 불안정할 수 있습니다. 언제든지 릴리스로 돌아갈 수 있습니다.",
|
||||
"releaseTitle": "릴리스 채널로 전환",
|
||||
"releaseMessage": "릴리스로 전환하면 Git 저장소가 제거되고 최신 안정 버전이 설치됩니다. 이후 업데이트는 안정 버전만 사용됩니다.",
|
||||
"releaseMessage": "릴리스로 전환하면 최신 안정 버전 태그로 체크아웃됩니다. 언제든지 나이틀리로 돌아갈 수 있습니다.",
|
||||
"switching": "{channel} 채널로 전환 중...",
|
||||
"completed": "{channel} 채널로 전환 완료",
|
||||
"failed": "채널 전환 실패"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "Переключиться на Nightly",
|
||||
"nightlyMessage": "Переключение на Nightly инициализирует Git-репозиторий и отслеживает последние коммиты ветки main. Обновления чаще, но могут быть нестабильными. Вы можете вернуться к Release в любое время.",
|
||||
"releaseTitle": "Переключиться на Release",
|
||||
"releaseMessage": "Переключение на Release удалит Git-репозиторий и установит последнюю стабильную версию. Будущие обновления будут использовать только стабильные версии.",
|
||||
"releaseMessage": "Переключение на Release выполнит checkout последнего стабильного тега. Вы можете вернуться к Nightly в любое время.",
|
||||
"switching": "Переключение на канал {channel}...",
|
||||
"completed": "Успешно переключено на канал {channel}",
|
||||
"failed": "Не удалось переключить канал"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "切换到 Nightly",
|
||||
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
|
||||
"releaseTitle": "切换到稳定版",
|
||||
"releaseMessage": "切换到稳定版将移除 Git 仓库并安装最新的稳定发布版本,后续仅使用稳定版更新。",
|
||||
"releaseMessage": "切换到稳定版将检出最新的发布标签。可随时切换回每日构建版。",
|
||||
"switching": "正在切换到 {channel} 频道...",
|
||||
"completed": "已切换到 {channel} 频道",
|
||||
"failed": "切换频道失败"
|
||||
|
||||
@@ -1782,7 +1782,7 @@
|
||||
"nightlyTitle": "切换到 Nightly",
|
||||
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
|
||||
"releaseTitle": "切换到稳定版",
|
||||
"releaseMessage": "切换到稳定版将移除 Git 仓库并安装最新的稳定发布版本,后续仅使用稳定版更新。",
|
||||
"releaseMessage": "切換到穩定版將檢出最新的發布標籤。可隨時切換回每日構建版。",
|
||||
"switching": "正在切換到 {channel} 頻道...",
|
||||
"completed": "已切換到 {channel} 頻道",
|
||||
"failed": "切換頻道失敗"
|
||||
|
||||
@@ -251,7 +251,7 @@ class UpdateRoutes:
|
||||
if os.path.exists(settings_path):
|
||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||
settings_backup = f.read()
|
||||
logger.info("Backed up settings.json (%d bytes)", len(settings_backup))
|
||||
logger.debug("Backed up settings.json (%d bytes)", len(settings_backup))
|
||||
|
||||
staged_backup_dir, staged_items = _stage_preserved_items(plugin_root)
|
||||
try:
|
||||
@@ -266,7 +266,7 @@ class UpdateRoutes:
|
||||
if settings_backup and success:
|
||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||
f.write(settings_backup)
|
||||
logger.info("Restored settings.json content")
|
||||
logger.debug("Restored settings.json content (%d bytes)", len(settings_backup))
|
||||
|
||||
if success:
|
||||
return web.json_response({
|
||||
@@ -291,9 +291,11 @@ class UpdateRoutes:
|
||||
async def switch_channel(request):
|
||||
"""
|
||||
Switch between release and nightly update channels.
|
||||
|
||||
Release → Nightly: Initialize a Git repository (from ZIP/CM stable mode)
|
||||
Nightly → Release: Remove .git, download latest release ZIP, write .tracking
|
||||
|
||||
ZIP/CNR install → Nightly: git init + checkout main (one-way upgrade)
|
||||
Git install → Release: git checkout latest tag (.git preserved)
|
||||
ZIP/CNR install → Release: ZIP download (no .git, stays in ZIP mode)
|
||||
Git install → Nightly: git checkout main + pull
|
||||
"""
|
||||
try:
|
||||
body = await request.json() if request.has_body else {}
|
||||
@@ -313,7 +315,7 @@ class UpdateRoutes:
|
||||
if os.path.exists(settings_path):
|
||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||
settings_backup = f.read()
|
||||
logger.info("Backed up settings.json before channel switch (%d bytes)", len(settings_backup))
|
||||
logger.debug("Backed up settings.json before channel switch (%d bytes)", len(settings_backup))
|
||||
|
||||
staged_backup_dir, staged_items = _stage_preserved_items(plugin_root)
|
||||
try:
|
||||
@@ -336,28 +338,24 @@ class UpdateRoutes:
|
||||
finally:
|
||||
UpdateRoutes._restore_git(git_backup, git_folder, success, 'nightly')
|
||||
else:
|
||||
git_backup = None
|
||||
if os.path.exists(git_folder):
|
||||
git_backup = UpdateRoutes._backup_git(git_folder, 'release')
|
||||
|
||||
success = False
|
||||
new_version = ''
|
||||
try:
|
||||
if os.path.exists(git_folder):
|
||||
shutil.rmtree(git_folder)
|
||||
if os.path.exists(git_folder):
|
||||
success, new_version = await UpdateRoutes._perform_git_update(
|
||||
plugin_root, nightly=False
|
||||
)
|
||||
else:
|
||||
tracking_file = os.path.join(plugin_root, '.tracking')
|
||||
if os.path.exists(tracking_file):
|
||||
os.remove(tracking_file)
|
||||
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
|
||||
finally:
|
||||
UpdateRoutes._restore_git(git_backup, git_folder, success, 'release')
|
||||
finally:
|
||||
_restore_preserved_items(plugin_root, staged_backup_dir, staged_items)
|
||||
|
||||
if settings_backup and success:
|
||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||
f.write(settings_backup)
|
||||
logger.info("Restored settings.json content after channel switch")
|
||||
logger.debug("Restored settings.json content after channel switch (%d bytes)", len(settings_backup))
|
||||
|
||||
if success:
|
||||
return web.json_response({
|
||||
|
||||
@@ -475,13 +475,19 @@ class MetadataUpdater:
|
||||
return False
|
||||
|
||||
model_folder = get_model_folder(model_hash)
|
||||
if not model_folder:
|
||||
if not model_folder or not os.path.isdir(model_folder):
|
||||
return False
|
||||
|
||||
civitai = getattr(metadata, "civitai", None)
|
||||
if not isinstance(civitai, dict):
|
||||
return False
|
||||
|
||||
# Read the directory listing once so every image entry reuses it.
|
||||
try:
|
||||
dir_entries = os.listdir(model_folder)
|
||||
except OSError:
|
||||
dir_entries = []
|
||||
|
||||
has_changes = False
|
||||
|
||||
custom_images = civitai.get("customImages")
|
||||
@@ -493,24 +499,15 @@ class MetadataUpdater:
|
||||
if not img_id:
|
||||
continue
|
||||
|
||||
if not os.path.isdir(model_folder):
|
||||
prefix = f"custom_{img_id}"
|
||||
found = any(
|
||||
f.startswith(prefix) and os.path.isfile(
|
||||
os.path.join(model_folder, f)
|
||||
)
|
||||
for f in dir_entries
|
||||
)
|
||||
if not found:
|
||||
stale.append(idx)
|
||||
else:
|
||||
found = False
|
||||
try:
|
||||
prefix = f"custom_{img_id}"
|
||||
for fname in os.listdir(model_folder):
|
||||
if fname.startswith(prefix) and os.path.isfile(
|
||||
os.path.join(model_folder, fname)
|
||||
):
|
||||
found = True
|
||||
break
|
||||
except OSError:
|
||||
stale.append(idx)
|
||||
continue
|
||||
|
||||
if not found:
|
||||
stale.append(idx)
|
||||
|
||||
if stale:
|
||||
for idx in reversed(stale):
|
||||
@@ -532,22 +529,9 @@ class MetadataUpdater:
|
||||
# is gone.
|
||||
continue
|
||||
|
||||
if not os.path.isdir(model_folder):
|
||||
prefix = f"image_{idx}."
|
||||
if not any(f.startswith(prefix) for f in dir_entries):
|
||||
stale.append(idx)
|
||||
else:
|
||||
found = False
|
||||
try:
|
||||
prefix = f"image_{idx}."
|
||||
for fname in os.listdir(model_folder):
|
||||
if fname.startswith(prefix):
|
||||
found = True
|
||||
break
|
||||
except OSError:
|
||||
stale.append(idx)
|
||||
continue
|
||||
|
||||
if not found:
|
||||
stale.append(idx)
|
||||
|
||||
if stale:
|
||||
for idx in reversed(stale):
|
||||
|
||||
@@ -3,9 +3,16 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import shutil
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.example_images_paths import iter_library_roots
|
||||
from ..utils.example_images_paths import (
|
||||
get_example_images_root,
|
||||
is_hash_folder,
|
||||
iter_library_roots,
|
||||
uses_library_scoped_folders,
|
||||
_library_folder_has_only_hash_dirs,
|
||||
)
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.example_images_processor import ExampleImagesProcessor
|
||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||
@@ -36,6 +43,90 @@ settings = _SettingsProxy()
|
||||
class ExampleImagesMigration:
|
||||
"""Handles migrations for example images naming conventions"""
|
||||
|
||||
@staticmethod
|
||||
def _consolidate_library_folders():
|
||||
"""Move hash folders from library-named subdirectories back to root.
|
||||
|
||||
When a user switches from multi-library mode back to single-library
|
||||
mode, example images previously stored under e.g.
|
||||
``<root>/default/<hash>/`` need to be moved back to
|
||||
``<root>/<hash>/``. Running this once at startup removes the need
|
||||
for ``get_model_folder()`` to perform directory scans on every
|
||||
request.
|
||||
"""
|
||||
if uses_library_scoped_folders():
|
||||
return
|
||||
|
||||
root = get_example_images_root()
|
||||
if not root or not os.path.isdir(root):
|
||||
return
|
||||
|
||||
moved: list[str] = []
|
||||
cleaned: list[str] = []
|
||||
|
||||
try:
|
||||
for entry in os.listdir(root):
|
||||
# Fast regex checks first — no filesystem I/O.
|
||||
if is_hash_folder(entry) or entry == "_deleted":
|
||||
continue
|
||||
|
||||
entry_path = os.path.join(root, entry)
|
||||
if not os.path.isdir(entry_path):
|
||||
continue
|
||||
if not _library_folder_has_only_hash_dirs(entry_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
for hash_entry in os.listdir(entry_path):
|
||||
hash_path = os.path.join(entry_path, hash_entry)
|
||||
if not os.path.isdir(hash_path) or not is_hash_folder(hash_entry):
|
||||
continue
|
||||
target = os.path.join(root, hash_entry)
|
||||
if not os.path.exists(target):
|
||||
try:
|
||||
shutil.move(hash_path, target)
|
||||
moved.append(hash_entry)
|
||||
except (OSError, shutil.Error) as exc:
|
||||
logger.error(
|
||||
"Failed to move '%s' → '%s': %s",
|
||||
hash_path, target, exc,
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.error(
|
||||
"Failed to list library subdirectory '%s': %s",
|
||||
entry_path, exc,
|
||||
)
|
||||
|
||||
try:
|
||||
remaining = os.listdir(entry_path)
|
||||
except OSError:
|
||||
remaining = []
|
||||
if not remaining:
|
||||
try:
|
||||
os.rmdir(entry_path)
|
||||
cleaned.append(entry)
|
||||
except OSError as exc:
|
||||
logger.debug(
|
||||
"Could not remove empty library dir '%s': %s",
|
||||
entry_path, exc,
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.error(
|
||||
"Failed to list example images root during consolidation: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
if moved:
|
||||
logger.info(
|
||||
"Consolidated %d example image folder(s) to root",
|
||||
len(moved),
|
||||
)
|
||||
if cleaned:
|
||||
logger.info(
|
||||
"Removed %d empty library directories",
|
||||
len(cleaned),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def check_and_run_migrations():
|
||||
"""Check if migrations are needed and run them in background"""
|
||||
@@ -44,6 +135,10 @@ class ExampleImagesMigration:
|
||||
logger.debug("No example images path configured or path doesn't exist, skipping migrations")
|
||||
return
|
||||
|
||||
# Run library-to-root consolidation once at startup so the hot
|
||||
# path (get_model_folder) stays a pure-path computation.
|
||||
ExampleImagesMigration._consolidate_library_folders()
|
||||
|
||||
for library_name, library_path in iter_library_roots():
|
||||
if not library_path or not os.path.exists(library_path):
|
||||
continue
|
||||
|
||||
@@ -83,7 +83,12 @@ def ensure_library_root_exists(library_name: Optional[str] = None) -> str:
|
||||
|
||||
|
||||
def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str:
|
||||
"""Return the folder path for a model's example images."""
|
||||
"""Return the folder path for a model's example images.
|
||||
|
||||
Multi-library ↔ single-library consolidation is handled once at startup by
|
||||
``ExampleImagesMigration._consolidate_library_folders`` — this function is a
|
||||
pure path computation on the hot path (no directory scans).
|
||||
"""
|
||||
|
||||
if not model_hash:
|
||||
return ""
|
||||
@@ -113,35 +118,6 @@ def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str
|
||||
exc,
|
||||
)
|
||||
return legacy_folder
|
||||
elif not os.path.exists(resolved_folder):
|
||||
# Reverse migration: when consolidating from multi-library to
|
||||
# single-library mode (e.g. after "default" was cleaned up), look
|
||||
# for existing example images inside library-named subdirectories
|
||||
# and bring them back to the root level.
|
||||
root = get_example_images_root()
|
||||
if root:
|
||||
try:
|
||||
for entry in os.listdir(root):
|
||||
entry_path = os.path.join(root, entry)
|
||||
if not os.path.isdir(entry_path):
|
||||
continue
|
||||
if is_hash_folder(entry) or entry == "_deleted":
|
||||
continue
|
||||
if not _library_folder_has_only_hash_dirs(entry_path):
|
||||
continue
|
||||
legacy = os.path.join(entry_path, normalized_hash)
|
||||
if os.path.exists(legacy):
|
||||
shutil.move(legacy, resolved_folder)
|
||||
logger.info(
|
||||
"Consolidated example images from '%s' to '%s'",
|
||||
legacy, resolved_folder,
|
||||
)
|
||||
break
|
||||
except OSError as exc:
|
||||
logger.error(
|
||||
"Failed to consolidate example images during "
|
||||
"library merge: %s", exc,
|
||||
)
|
||||
|
||||
return resolved_folder
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ async def test_switch_channel_to_nightly_with_git_calls_git_update(monkeypatch,
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_channel_to_release_with_git_downloads_zip(monkeypatch, tmp_path):
|
||||
async def test_switch_channel_to_release_with_git_calls_git_update(monkeypatch, tmp_path):
|
||||
routes_file = tmp_path / "py" / "routes" / "update_routes.py"
|
||||
routes_file.parent.mkdir(parents=True)
|
||||
routes_file.write_text("")
|
||||
@@ -353,11 +353,11 @@ async def test_switch_channel_to_release_with_git_downloads_zip(monkeypatch, tmp
|
||||
|
||||
(tmp_path / ".git").mkdir()
|
||||
|
||||
async def _fake_zip(*args, **kwargs):
|
||||
async def _fake_git_update(*args, **kwargs):
|
||||
return True, "v9.9.9"
|
||||
|
||||
monkeypatch.setattr(
|
||||
update_routes.UpdateRoutes, "_download_and_replace_zip", _fake_zip
|
||||
update_routes.UpdateRoutes, "_perform_git_update", _fake_git_update
|
||||
)
|
||||
|
||||
req = _fake_request({"channel": "release"})
|
||||
|
||||
Reference in New Issue
Block a user