Compare commits

...

5 Commits

Author SHA1 Message Date
Will Miao
99e65cccd8 fix(update): downgrade settings backup/restore logs from INFO to DEBUG 2026-07-30 20:32:00 +08:00
Will Miao
3bdacb8f46 fix(test): update release channel git test to mock _perform_git_update instead of _download_and_replace_zip 2026-07-30 18:35:43 +08:00
Will Miao
b4f9c224d3 fix(example-images): move multi→single-library consolidation to startup, eliminate per-request os.listdir()
Move reverse-migration logic from get_model_folder() (hot path, called on
every metadata/example-images request) to ExampleImagesMigration, where it
runs once at startup.  On network storage this was causing 22-38s delays
per LoRA card click.

Additionally optimize prune_stale_example_images() to read the directory
listing once instead of per image entry (O(N*M) → O(M)).  Also reorder
consolidation checks so regex filters run before filesystem stat calls.
2026-07-30 18:11:40 +08:00
Will Miao
5ec0399c81 fix(i18n): remove redundant 'preserved' sentence from release channel message, sync all 10 locales 2026-07-30 16:38:04 +08:00
Will Miao
b464fdc333 fix(update): preserve .git on release channel switch, use git checkout tag
Previously, switching to the release channel would delete .git/ and
fall back to a ZIP download. This broke update.bat, manual git
commands, and CM git-based update detection.

Now the release path uses git checkout <latest-tag> when .git exists,
and only falls back to ZIP when .git is absent (CM CNR installs).
.git is never deleted - the ZIP→nightly path remains a one-way
upgrade via _init_git_repo.

Also updates locale strings (en, zh-CN, zh-TW, ja) to remove the
now-inaccurate "remove the Git repository" wording.
2026-07-29 21:23:39 +08:00
15 changed files with 146 additions and 93 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -1782,7 +1782,7 @@
"nightlyTitle": "מעבר לערוץ Nightly",
"nightlyMessage": "מעבר ל-Nightly יאתחל מאגר Git ויעקוב אחר הקומיטים האחרונים בענף main. העדכונים תכופים יותר אך עשויים להיות לא יציבים. ניתן לחזור ל-Release בכל עת.",
"releaseTitle": "מעבר לערוץ Release",
"releaseMessage": "מעבר ל-Release יסיר את מאגר ה-Git ויתקין את הגרסה היציבה האחרונה. עדכונים עתידיים ישתמשו בגרסאות יציבות בלבד.",
"releaseMessage": "מעבר ל-Release יעבור לתגית הגרסה היציבה האחרונה. ניתן לחזור ל-Nightly בכל עת.",
"switching": "מעבר לערוץ {channel}...",
"completed": "המעבר לערוץ {channel} הושלם",
"failed": "החלפת ערוץ נכשלה"

View File

@@ -1782,7 +1782,7 @@
"nightlyTitle": "ナイトリーチャンネルに切り替え",
"nightlyMessage": "ナイトリーに切り替えると、Gitリポジトリが初期化され、mainブランチの最新コミットを追跡します。更新頻度は高くなりますが、不安定な場合があります。いつでもリリース版に戻せます。",
"releaseTitle": "リリースチャンネルに切り替え",
"releaseMessage": "リリースに切り替えると、Gitリポジトリが削除され、最新の安定版がインストールされます。以降の更新は安定版のみが使用されます。",
"releaseMessage": "リリースに切り替えると、最新の安定版タグにチェックアウトされます。いつでもNightlyに戻せます。",
"switching": "{channel} チャンネルに切り替え中...",
"completed": "{channel} チャンネルに切り替えました",
"failed": "チャンネルの切り替えに失敗しました"

View File

@@ -1782,7 +1782,7 @@
"nightlyTitle": "나이틀리 채널로 전환",
"nightlyMessage": "나이틀리로 전환하면 Git 저장소가 초기화되고 main 브랜치의 최신 커밋을 추적합니다. 업데이트 빈도는 높지만 불안정할 수 있습니다. 언제든지 릴리스로 돌아갈 수 있습니다.",
"releaseTitle": "릴리스 채널로 전환",
"releaseMessage": "릴리스로 전환하면 Git 저장소가 제거되고 최신 안정 버전이 설치됩니다. 이후 업데이트는 안정 버전만 사용됩니다.",
"releaseMessage": "릴리스로 전환하면 최신 안정 버전 태그로 체크아웃됩니다. 언제든지 나이틀리로 돌아갈 수 있습니다.",
"switching": "{channel} 채널로 전환 중...",
"completed": "{channel} 채널로 전환 완료",
"failed": "채널 전환 실패"

View File

@@ -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": "Не удалось переключить канал"

View File

@@ -1782,7 +1782,7 @@
"nightlyTitle": "切换到 Nightly",
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
"releaseTitle": "切换到稳定版",
"releaseMessage": "切换到稳定版将移除 Git 仓库并安装最新的稳定发布版本,后续仅使用稳定版更新。",
"releaseMessage": "切换到稳定版将检出最新的发布标签。可随时切换回每日构建版。",
"switching": "正在切换到 {channel} 频道...",
"completed": "已切换到 {channel} 频道",
"failed": "切换频道失败"

View File

@@ -1782,7 +1782,7 @@
"nightlyTitle": "切换到 Nightly",
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
"releaseTitle": "切换到稳定版",
"releaseMessage": "切换到稳定版将移除 Git 仓库并安装最新的稳定发布版本,后续仅使用稳定版更新。",
"releaseMessage": "切換到穩定版將檢出最新的發布標籤。可隨時切換回每日構建版。",
"switching": "正在切換到 {channel} 頻道...",
"completed": "已切換到 {channel} 頻道",
"failed": "切換頻道失敗"

View File

@@ -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({

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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"})