feat(example-images): add missing-only download path and skip existing files

Split the single-model and bulk context menu actions into 'Download
Missing Example Images' (regular endpoint, skips already-processed
models) and 'Re-process Example Images' (force endpoint, retries
failed models).

- start_download accepts model_hashes so a selected subset can be
  processed with the progress-aware skip logic; explicitly targeted
  models bypass the failed/processed model-level guards so per-image
  gaps are filled
- pre-download existence check in the processor skips network requests
  for image files already on disk across all download paths
- force download retries previously failed models and clears their
  failed status on success
- add i18n keys for the new menu items across all locales
This commit is contained in:
Will Miao
2026-08-03 20:52:46 +08:00
parent 191c4e03cd
commit 9087b4b07c
23 changed files with 20450 additions and 20060 deletions

View File

@@ -772,7 +772,8 @@
"unfavorite": "Aus Favoriten entfernen", "unfavorite": "Aus Favoriten entfernen",
"deleteAll": "Ausgewählte löschen", "deleteAll": "Ausgewählte löschen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen", "downloadMissingLoras": "Fehlende LoRAs herunterladen",
"downloadExamples": "Beispielbilder herunterladen", "downloadMissingExamples": "Fehlende Beispielbilder herunterladen",
"reprocessExamples": "Beispielbilder erneut verarbeiten",
"clear": "Auswahl löschen", "clear": "Auswahl löschen",
"skipMetadataRefreshCount": "Überspringen{count} Modelle", "skipMetadataRefreshCount": "Überspringen{count} Modelle",
"resumeMetadataRefreshCount": "Fortsetzen{count} Modelle", "resumeMetadataRefreshCount": "Fortsetzen{count} Modelle",
@@ -807,7 +808,8 @@
"sendToWorkflowAppend": "An Workflow senden (Anhängen)", "sendToWorkflowAppend": "An Workflow senden (Anhängen)",
"sendToWorkflowReplace": "An Workflow senden (Ersetzen)", "sendToWorkflowReplace": "An Workflow senden (Ersetzen)",
"openExamples": "Beispiele-Ordner öffnen", "openExamples": "Beispiele-Ordner öffnen",
"downloadExamples": "Beispielbilder herunterladen", "downloadExamples": "Fehlende Beispielbilder herunterladen",
"reprocessExamples": "Beispielbilder erneut verarbeiten",
"replacePreview": "Vorschau ersetzen", "replacePreview": "Vorschau ersetzen",
"setContentRating": "Inhaltsbewertung festlegen", "setContentRating": "Inhaltsbewertung festlegen",
"moveToFolder": "In Ordner verschieben", "moveToFolder": "In Ordner verschieben",

View File

@@ -772,7 +772,8 @@
"unfavorite": "Remove from Favorites", "unfavorite": "Remove from Favorites",
"deleteAll": "Delete Selected", "deleteAll": "Delete Selected",
"downloadMissingLoras": "Download Missing LoRAs", "downloadMissingLoras": "Download Missing LoRAs",
"downloadExamples": "Download Example Images", "downloadMissingExamples": "Download Missing Example Images",
"reprocessExamples": "Re-process Example Images",
"clear": "Clear Selection", "clear": "Clear Selection",
"skipMetadataRefreshCount": "Skip ({count} models)", "skipMetadataRefreshCount": "Skip ({count} models)",
"resumeMetadataRefreshCount": "Resume ({count} models)", "resumeMetadataRefreshCount": "Resume ({count} models)",
@@ -807,7 +808,8 @@
"sendToWorkflowAppend": "Send to Workflow (Append)", "sendToWorkflowAppend": "Send to Workflow (Append)",
"sendToWorkflowReplace": "Send to Workflow (Replace)", "sendToWorkflowReplace": "Send to Workflow (Replace)",
"openExamples": "Open Examples Folder", "openExamples": "Open Examples Folder",
"downloadExamples": "Download Example Images", "downloadExamples": "Download Missing Example Images",
"reprocessExamples": "Re-process Example Images",
"replacePreview": "Replace Preview", "replacePreview": "Replace Preview",
"setContentRating": "Set Content Rating", "setContentRating": "Set Content Rating",
"moveToFolder": "Move to Folder", "moveToFolder": "Move to Folder",

View File

@@ -772,7 +772,8 @@
"unfavorite": "Quitar de favoritos", "unfavorite": "Quitar de favoritos",
"deleteAll": "Eliminar seleccionados", "deleteAll": "Eliminar seleccionados",
"downloadMissingLoras": "Descargar LoRAs faltantes", "downloadMissingLoras": "Descargar LoRAs faltantes",
"downloadExamples": "Descargar imágenes de ejemplo", "downloadMissingExamples": "Descargar imágenes de ejemplo faltantes",
"reprocessExamples": "Reprocesar imágenes de ejemplo",
"clear": "Limpiar selección", "clear": "Limpiar selección",
"skipMetadataRefreshCount": "Omitir{count} modelos", "skipMetadataRefreshCount": "Omitir{count} modelos",
"resumeMetadataRefreshCount": "Reanudar{count} modelos", "resumeMetadataRefreshCount": "Reanudar{count} modelos",
@@ -807,7 +808,8 @@
"sendToWorkflowAppend": "Enviar al flujo de trabajo (Añadir)", "sendToWorkflowAppend": "Enviar al flujo de trabajo (Añadir)",
"sendToWorkflowReplace": "Enviar al flujo de trabajo (Reemplazar)", "sendToWorkflowReplace": "Enviar al flujo de trabajo (Reemplazar)",
"openExamples": "Abrir carpeta de ejemplos", "openExamples": "Abrir carpeta de ejemplos",
"downloadExamples": "Descargar imágenes de ejemplo", "downloadExamples": "Descargar imágenes de ejemplo faltantes",
"reprocessExamples": "Reprocesar imágenes de ejemplo",
"replacePreview": "Reemplazar vista previa", "replacePreview": "Reemplazar vista previa",
"setContentRating": "Establecer clasificación de contenido", "setContentRating": "Establecer clasificación de contenido",
"moveToFolder": "Mover a carpeta", "moveToFolder": "Mover a carpeta",

View File

@@ -772,7 +772,8 @@
"unfavorite": "Retirer des favoris", "unfavorite": "Retirer des favoris",
"deleteAll": "Supprimer la sélection", "deleteAll": "Supprimer la sélection",
"downloadMissingLoras": "Télécharger les LoRAs manquants", "downloadMissingLoras": "Télécharger les LoRAs manquants",
"downloadExamples": "Télécharger les images d'exemple", "downloadMissingExamples": "Télécharger les images d'exemple manquantes",
"reprocessExamples": "Retraiter les images d'exemple",
"clear": "Effacer la sélection", "clear": "Effacer la sélection",
"skipMetadataRefreshCount": "Ignorer{count} modèles", "skipMetadataRefreshCount": "Ignorer{count} modèles",
"resumeMetadataRefreshCount": "Reprendre{count} modèles", "resumeMetadataRefreshCount": "Reprendre{count} modèles",
@@ -807,7 +808,8 @@
"sendToWorkflowAppend": "Envoyer vers le workflow (Ajouter)", "sendToWorkflowAppend": "Envoyer vers le workflow (Ajouter)",
"sendToWorkflowReplace": "Envoyer vers le workflow (Remplacer)", "sendToWorkflowReplace": "Envoyer vers le workflow (Remplacer)",
"openExamples": "Ouvrir le dossier d'exemples", "openExamples": "Ouvrir le dossier d'exemples",
"downloadExamples": "Télécharger les images d'exemple", "downloadExamples": "Télécharger les images d'exemple manquantes",
"reprocessExamples": "Retraiter les images d'exemple",
"replacePreview": "Remplacer l'aperçu", "replacePreview": "Remplacer l'aperçu",
"setContentRating": "Définir la classification du contenu", "setContentRating": "Définir la classification du contenu",
"moveToFolder": "Déplacer vers un dossier", "moveToFolder": "Déplacer vers un dossier",

View File

@@ -772,7 +772,8 @@
"unfavorite": "הסר ממועדפים", "unfavorite": "הסר ממועדפים",
"deleteAll": "מחק נבחרים", "deleteAll": "מחק נבחרים",
"downloadMissingLoras": "הורדת LoRAs חסרים", "downloadMissingLoras": "הורדת LoRAs חסרים",
"downloadExamples": "הורד תמונות דוגמה", "downloadMissingExamples": "הורדת תמונות דוגמה חסרות",
"reprocessExamples": "עיבוד מחדש של תמונות דוגמה",
"clear": "נקה בחירה", "clear": "נקה בחירה",
"skipMetadataRefreshCount": "דילוג({count} מודלים)", "skipMetadataRefreshCount": "דילוג({count} מודלים)",
"resumeMetadataRefreshCount": "המשך({count} מודלים)", "resumeMetadataRefreshCount": "המשך({count} מודלים)",
@@ -807,7 +808,8 @@
"sendToWorkflowAppend": "שלח ל-Workflow (הוסף)", "sendToWorkflowAppend": "שלח ל-Workflow (הוסף)",
"sendToWorkflowReplace": "שלח ל-Workflow (החלף)", "sendToWorkflowReplace": "שלח ל-Workflow (החלף)",
"openExamples": "פתח תיקיית דוגמאות", "openExamples": "פתח תיקיית דוגמאות",
"downloadExamples": "הורד תמונות דוגמה", "downloadExamples": "הורדת תמונות דוגמה חסרות",
"reprocessExamples": "עיבוד מחדש של תמונות דוגמה",
"replacePreview": "החלף תצוגה מקדימה", "replacePreview": "החלף תצוגה מקדימה",
"setContentRating": "הגדר דירוג תוכן", "setContentRating": "הגדר דירוג תוכן",
"moveToFolder": "העבר לתיקייה", "moveToFolder": "העבר לתיקייה",

View File

@@ -772,7 +772,8 @@
"unfavorite": "お気に入りから削除", "unfavorite": "お気に入りから削除",
"deleteAll": "選択したものを削除", "deleteAll": "選択したものを削除",
"downloadMissingLoras": "不足している LoRA をダウンロード", "downloadMissingLoras": "不足している LoRA をダウンロード",
"downloadExamples": "画像をダウンロード", "downloadMissingExamples": "不足しているサンプル画像をダウンロード",
"reprocessExamples": "サンプル画像を再処理",
"clear": "選択をクリア", "clear": "選択をクリア",
"skipMetadataRefreshCount": "スキップ({count}モデル)", "skipMetadataRefreshCount": "スキップ({count}モデル)",
"resumeMetadataRefreshCount": "再開({count}モデル)", "resumeMetadataRefreshCount": "再開({count}モデル)",
@@ -807,7 +808,8 @@
"sendToWorkflowAppend": "ワークフローに送信(追加)", "sendToWorkflowAppend": "ワークフローに送信(追加)",
"sendToWorkflowReplace": "ワークフローに送信(置換)", "sendToWorkflowReplace": "ワークフローに送信(置換)",
"openExamples": "例画像フォルダを開く", "openExamples": "例画像フォルダを開く",
"downloadExamples": "画像をダウンロード", "downloadExamples": "不足しているサンプル画像をダウンロード",
"reprocessExamples": "サンプル画像を再処理",
"replacePreview": "プレビューを置換", "replacePreview": "プレビューを置換",
"setContentRating": "コンテンツレーティングを設定", "setContentRating": "コンテンツレーティングを設定",
"moveToFolder": "フォルダに移動", "moveToFolder": "フォルダに移動",

View File

@@ -772,7 +772,8 @@
"unfavorite": "즐겨찾기 해제", "unfavorite": "즐겨찾기 해제",
"deleteAll": "선택된 항목 삭제", "deleteAll": "선택된 항목 삭제",
"downloadMissingLoras": "누락된 LoRA 다운로드", "downloadMissingLoras": "누락된 LoRA 다운로드",
"downloadExamples": "예시 이미지 다운로드", "downloadMissingExamples": "누락된 예시 이미지 다운로드",
"reprocessExamples": "예시 이미지 다시 처리",
"clear": "선택 지우기", "clear": "선택 지우기",
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)", "skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
"resumeMetadataRefreshCount": "재개({count}개 모델)", "resumeMetadataRefreshCount": "재개({count}개 모델)",
@@ -807,7 +808,8 @@
"sendToWorkflowAppend": "워크플로로 전송 (추가)", "sendToWorkflowAppend": "워크플로로 전송 (추가)",
"sendToWorkflowReplace": "워크플로로 전송 (교체)", "sendToWorkflowReplace": "워크플로로 전송 (교체)",
"openExamples": "예시 폴더 열기", "openExamples": "예시 폴더 열기",
"downloadExamples": "예시 이미지 다운로드", "downloadExamples": "누락된 예시 이미지 다운로드",
"reprocessExamples": "예시 이미지 다시 처리",
"replacePreview": "미리보기 교체", "replacePreview": "미리보기 교체",
"setContentRating": "콘텐츠 등급 설정", "setContentRating": "콘텐츠 등급 설정",
"moveToFolder": "폴더로 이동", "moveToFolder": "폴더로 이동",

View File

@@ -772,7 +772,8 @@
"unfavorite": "Удалить из избранного", "unfavorite": "Удалить из избранного",
"deleteAll": "Удалить выбранные", "deleteAll": "Удалить выбранные",
"downloadMissingLoras": "Скачать отсутствующие LoRAs", "downloadMissingLoras": "Скачать отсутствующие LoRAs",
"downloadExamples": "Загрузить примеры изображений", "downloadMissingExamples": "Загрузить недостающие примеры изображений",
"reprocessExamples": "Повторно обработать примеры изображений",
"clear": "Очистить выбор", "clear": "Очистить выбор",
"skipMetadataRefreshCount": "Пропустить({count} моделей)", "skipMetadataRefreshCount": "Пропустить({count} моделей)",
"resumeMetadataRefreshCount": "Возобновить({count} моделей)", "resumeMetadataRefreshCount": "Возобновить({count} моделей)",
@@ -807,7 +808,8 @@
"sendToWorkflowAppend": "Отправить в Workflow (Добавить)", "sendToWorkflowAppend": "Отправить в Workflow (Добавить)",
"sendToWorkflowReplace": "Отправить в Workflow (Заменить)", "sendToWorkflowReplace": "Отправить в Workflow (Заменить)",
"openExamples": "Открыть папку примеров", "openExamples": "Открыть папку примеров",
"downloadExamples": "Загрузить примеры изображений", "downloadExamples": "Загрузить недостающие примеры изображений",
"reprocessExamples": "Повторно обработать примеры изображений",
"replacePreview": "Заменить превью", "replacePreview": "Заменить превью",
"setContentRating": "Установить рейтинг контента", "setContentRating": "Установить рейтинг контента",
"moveToFolder": "Переместить в папку", "moveToFolder": "Переместить в папку",

View File

@@ -772,7 +772,8 @@
"unfavorite": "取消收藏", "unfavorite": "取消收藏",
"deleteAll": "删除已选", "deleteAll": "删除已选",
"downloadMissingLoras": "下载缺失的 LoRAs", "downloadMissingLoras": "下载缺失的 LoRAs",
"downloadExamples": "下载示例图片", "downloadMissingExamples": "下载缺失的示例图片",
"reprocessExamples": "重新处理示例图片",
"clear": "清除选择", "clear": "清除选择",
"skipMetadataRefreshCount": "跳过({count} 个模型)", "skipMetadataRefreshCount": "跳过({count} 个模型)",
"resumeMetadataRefreshCount": "恢复({count} 个模型)", "resumeMetadataRefreshCount": "恢复({count} 个模型)",
@@ -807,7 +808,8 @@
"sendToWorkflowAppend": "发送到工作流(追加)", "sendToWorkflowAppend": "发送到工作流(追加)",
"sendToWorkflowReplace": "发送到工作流(替换)", "sendToWorkflowReplace": "发送到工作流(替换)",
"openExamples": "打开示例文件夹", "openExamples": "打开示例文件夹",
"downloadExamples": "下载示例图片", "downloadExamples": "下载缺失的示例图片",
"reprocessExamples": "重新处理示例图片",
"replacePreview": "替换预览", "replacePreview": "替换预览",
"setContentRating": "设置内容评级", "setContentRating": "设置内容评级",
"moveToFolder": "移动到文件夹", "moveToFolder": "移动到文件夹",

View File

@@ -772,7 +772,8 @@
"unfavorite": "取消收藏", "unfavorite": "取消收藏",
"deleteAll": "刪除所選", "deleteAll": "刪除所選",
"downloadMissingLoras": "下載缺失的 LoRAs", "downloadMissingLoras": "下載缺失的 LoRAs",
"downloadExamples": "下載範例圖片", "downloadMissingExamples": "下載缺少的範例圖片",
"reprocessExamples": "重新處理範例圖片",
"clear": "清除選取", "clear": "清除選取",
"skipMetadataRefreshCount": "跳過({count} 個模型)", "skipMetadataRefreshCount": "跳過({count} 個模型)",
"resumeMetadataRefreshCount": "恢復({count} 個模型)", "resumeMetadataRefreshCount": "恢復({count} 個模型)",
@@ -807,7 +808,8 @@
"sendToWorkflowAppend": "傳送到工作流(附加)", "sendToWorkflowAppend": "傳送到工作流(附加)",
"sendToWorkflowReplace": "傳送到工作流(取代)", "sendToWorkflowReplace": "傳送到工作流(取代)",
"openExamples": "開啟範例資料夾", "openExamples": "開啟範例資料夾",
"downloadExamples": "下載範例圖片", "downloadExamples": "下載缺少的範例圖片",
"reprocessExamples": "重新處理範例圖片",
"replacePreview": "更換預覽圖", "replacePreview": "更換預覽圖",
"setContentRating": "設定內容分級", "setContentRating": "設定內容分級",
"moveToFolder": "移動到資料夾", "moveToFolder": "移動到資料夾",

View File

@@ -172,6 +172,7 @@ class DownloadManager:
model_types = data.get("model_types", ["lora", "checkpoint"]) model_types = data.get("model_types", ["lora", "checkpoint"])
delay = float(data.get("delay", 0.2)) delay = float(data.get("delay", 0.2))
force = data.get("force", False) force = data.get("force", False)
model_hashes = data.get("model_hashes", [])
# Step 2: Validate configuration (fast lookup) # Step 2: Validate configuration (fast lookup)
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
@@ -241,6 +242,7 @@ class DownloadManager:
delay, delay,
active_library, active_library,
force, force,
model_hashes,
) )
) )
@@ -577,8 +579,9 @@ class DownloadManager:
delay, delay,
library_name, library_name,
force: bool = False, force: bool = False,
model_hashes: list[str] | None = None,
): ):
"""Download example images for all models.""" """Download example images for all models (or only the given hashes)."""
downloader = await get_downloader() downloader = await get_downloader()
@@ -606,6 +609,18 @@ class DownloadManager:
if model.get("sha256"): if model.get("sha256"):
all_models.append((scanner_type, model, scanner)) all_models.append((scanner_type, model, scanner))
# Restrict to the requested hashes when provided (empty = all models).
# Explicit targets are a directed user request, so previously failed
# models are retried instead of skipped.
explicit_targets = bool(model_hashes)
if model_hashes:
hash_set = {h.lower() for h in model_hashes}
all_models = [
(scanner_type, model, scanner)
for scanner_type, model, scanner in all_models
if model.get("sha256", "").lower() in hash_set
]
# Update total count # Update total count
self._progress["total"] = len(all_models) self._progress["total"] = len(all_models)
logger.debug(f"Found {self._progress['total']} models to process") logger.debug(f"Found {self._progress['total']} models to process")
@@ -629,6 +644,7 @@ class DownloadManager:
downloader, downloader,
library_name, library_name,
force, force,
explicit_targets,
) )
# Update progress # Update progress
@@ -725,6 +741,7 @@ class DownloadManager:
downloader, downloader,
library_name, library_name,
force: bool = False, force: bool = False,
explicit_targets: bool = False,
): ):
"""Process a single model download.""" """Process a single model download."""
@@ -747,8 +764,9 @@ class DownloadManager:
self._progress["current_model"] = f"{model_name} ({model_hash[:8]})" self._progress["current_model"] = f"{model_name} ({model_hash[:8]})"
await self._broadcast_progress(status="running") await self._broadcast_progress(status="running")
# Skip if already in failed models (unless force mode is enabled) # Skip if already in failed models (unless force mode is enabled or
if not force and model_hash in self._progress["failed_models"]: # the model was explicitly targeted by hash)
if not force and not explicit_targets and model_hash in self._progress["failed_models"]:
logger.debug(f"Skipping known failed model: {model_name}") logger.debug(f"Skipping known failed model: {model_name}")
return False return False
@@ -757,6 +775,10 @@ class DownloadManager:
) )
existing_files = _model_directory_has_files(model_dir) existing_files = _model_directory_has_files(model_dir)
# Model-level guard: a populated folder counts as done. Explicitly
# targeted models bypass it so the per-image existence pre-check can
# fill individual gaps without re-fetching existing files.
if not explicit_targets:
# Skip if already processed AND directory exists with files # Skip if already processed AND directory exists with files
if model_hash in self._progress["processed_models"]: if model_hash in self._progress["processed_models"]:
if existing_files: if existing_files:
@@ -884,7 +906,7 @@ class DownloadManager:
model_name, model_name,
) )
# Clear failed_models so non-force runs can retry # Clear failed_models so non-force runs can retry
if force and model_hash in self._progress["failed_models"]: if (force or explicit_targets) and model_hash in self._progress["failed_models"]:
self._progress["failed_models"].discard(model_hash) self._progress["failed_models"].discard(model_hash)
logger.info( logger.info(
f"Removed {model_name} from failed_models after force retry with rate-limited images" f"Removed {model_name} from failed_models after force retry with rate-limited images"
@@ -904,7 +926,7 @@ class DownloadManager:
) )
elif success: elif success:
self._progress["processed_models"].add(model_hash) self._progress["processed_models"].add(model_hash)
if force and model_hash in self._progress["failed_models"]: if (force or explicit_targets) and model_hash in self._progress["failed_models"]:
self._progress["failed_models"].discard(model_hash) self._progress["failed_models"].discard(model_hash)
logger.info( logger.info(
f"Removed {model_name} from failed_models after successful force retry" f"Removed {model_name} from failed_models after successful force retry"

View File

@@ -113,6 +113,26 @@ class ExampleImagesProcessor:
message = str(error).lower() message = str(error).lower()
return '404' in message or 'file not found' in message return '404' in message or 'file not found' in message
@staticmethod
def _example_image_file_exists(model_dir: str, index: int, media_type_hint: str | None = None) -> bool:
"""Return True when the file that would be written for a media index already exists.
The final filename (``image_{index}{extension}``) depends on the downloaded
content, so the extension cannot be known ahead of time. The post-download
check skips the write when the exact target file exists; this pre-check
approximates that with the candidate extensions for the media type (videos
only when the metadata hints at a video) so the network request is avoided
for files that already exist on disk.
"""
if media_type_hint == "video":
extensions = SUPPORTED_MEDIA_EXTENSIONS['videos']
else:
extensions = SUPPORTED_MEDIA_EXTENSIONS['images']
return any(
os.path.exists(os.path.join(model_dir, f"image_{index}{ext}"))
for ext in extensions
)
@staticmethod @staticmethod
async def download_model_images(model_hash, model_name, model_images, model_dir, optimize, downloader): async def download_model_images(model_hash, model_name, model_images, model_dir, optimize, downloader):
"""Download images for a single model """Download images for a single model
@@ -140,6 +160,11 @@ class ExampleImagesProcessor:
if optimize and 'civitai.com' in image_url: if optimize and 'civitai.com' in image_url:
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url) image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
# Skip the download when the file already exists on disk
if ExampleImagesProcessor._example_image_file_exists(model_dir, i, image.get("type")):
logger.debug("File already exists, skipping download for %s", image_url)
continue
# Download the file first to determine the actual file type # Download the file first to determine the actual file type
try: try:
logger.debug(f"Downloading media file {i} for {model_name}") logger.debug(f"Downloading media file {i} for {model_name}")
@@ -229,6 +254,11 @@ class ExampleImagesProcessor:
if optimize and 'civitai.com' in image_url: if optimize and 'civitai.com' in image_url:
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url) image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
# Skip the download when the file already exists on disk
if ExampleImagesProcessor._example_image_file_exists(model_dir, i, image.get("type")):
logger.debug("File already exists, skipping download for %s", image_url)
continue
async def _attempt_download() -> tuple: async def _attempt_download() -> tuple:
logger.debug("Downloading media file %s for %s", i, model_name) logger.debug("Downloading media file %s for %s", i, model_name)
return await downloader.download_to_memory( return await downloader.download_to_memory(

View File

@@ -184,7 +184,8 @@ export const DOWNLOAD_ENDPOINTS = {
downloadGet: '/api/lm/download-model-get', downloadGet: '/api/lm/download-model-get',
cancelGet: '/api/lm/cancel-download-get', cancelGet: '/api/lm/cancel-download-get',
progress: '/api/lm/download-progress', progress: '/api/lm/download-progress',
exampleImages: '/api/lm/force-download-example-images' // New endpoint for downloading example images exampleImages: '/api/lm/force-download-example-images', // Re-process example images ignoring previous status
exampleImagesMissing: '/api/lm/download-example-images' // Download only missing example images
}; };
// Hugging Face API endpoints // Hugging Face API endpoints

View File

@@ -1641,7 +1641,7 @@ export class BaseModelApiClient {
} }
} }
async downloadExampleImages(modelHashes, modelTypes = null) { async downloadExampleImages(modelHashes, modelTypes = null, { force = true } = {}) {
let ws = null; let ws = null;
await state.loadingManager.showWithProgress(async (loading) => { await state.loadingManager.showWithProgress(async (loading) => {
@@ -1700,8 +1700,13 @@ export class BaseModelApiClient {
// Determine optimize setting // Determine optimize setting
const optimize = state.global?.settings?.optimize_example_images ?? true; const optimize = state.global?.settings?.optimize_example_images ?? true;
// force=false routes to the regular endpoint, which skips already-processed models
const endpoint = force
? DOWNLOAD_ENDPOINTS.exampleImages
: DOWNLOAD_ENDPOINTS.exampleImagesMissing;
// Make the API request to start the download process // Make the API request to start the download process
const response = await fetch(DOWNLOAD_ENDPOINTS.exampleImages, { const response = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -1710,6 +1715,7 @@ export class BaseModelApiClient {
model_hashes: modelHashes, model_hashes: modelHashes,
output_dir: outputDir, output_dir: outputDir,
optimize: optimize, optimize: optimize,
force: force,
model_types: modelTypes || [this.apiConfig.config.singularName] model_types: modelTypes || [this.apiConfig.config.singularName]
}) })
}); });

View File

@@ -144,6 +144,12 @@ export class BulkContextMenu extends BaseContextMenu {
downloadExampleImagesItem.style.display = modelPages.includes(currentModelType) ? 'flex' : 'none'; downloadExampleImagesItem.style.display = modelPages.includes(currentModelType) ? 'flex' : 'none';
} }
const downloadMissingExampleImagesItem = this.menu.querySelector('[data-action="download-missing-example-images"]');
if (downloadMissingExampleImagesItem) {
// Show on model pages (loras, checkpoints, embeddings), hide on recipes
downloadMissingExampleImagesItem.style.display = ['loras', 'checkpoints', 'embeddings'].includes(currentModelType) ? 'flex' : 'none';
}
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]'); const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]'); const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
@@ -294,8 +300,11 @@ export class BulkContextMenu extends BaseContextMenu {
case 'download-missing-loras': case 'download-missing-loras':
this.handleDownloadMissingLoras(); this.handleDownloadMissingLoras();
break; break;
case 'download-missing-example-images':
this.handleDownloadExampleImages({ force: false });
break;
case 'download-example-images': case 'download-example-images':
this.handleDownloadExampleImages(); this.handleDownloadExampleImages({ force: true });
break; break;
case 'clear': case 'clear':
bulkManager.clearSelection(); bulkManager.clearSelection();
@@ -340,7 +349,7 @@ export class BulkContextMenu extends BaseContextMenu {
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes); await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
} }
async handleDownloadExampleImages() { async handleDownloadExampleImages({ force = true } = {}) {
if (state.selectedModels.size === 0) { if (state.selectedModels.size === 0) {
return; return;
} }
@@ -361,7 +370,7 @@ export class BulkContextMenu extends BaseContextMenu {
try { try {
const apiClient = getModelApiClient(); const apiClient = getModelApiClient();
await apiClient.downloadExampleImages([...hashes]); await apiClient.downloadExampleImages([...hashes], null, { force });
} catch (error) { } catch (error) {
console.error('Bulk download example images failed:', error); console.error('Bulk download example images failed:', error);
} }

View File

@@ -347,7 +347,10 @@ export const ModelContextMenuMixin = {
openExampleImagesFolder(this.currentCard.dataset.sha256); openExampleImagesFolder(this.currentCard.dataset.sha256);
return true; return true;
case 'download-examples': case 'download-examples':
this.downloadExampleImages(); this.downloadExampleImages(false);
return true;
case 'download-examples-force':
this.downloadExampleImages(true);
return true; return true;
case 'civitai': case 'civitai':
if (this.currentCard.dataset.from_civitai === 'true') { if (this.currentCard.dataset.from_civitai === 'true') {
@@ -378,7 +381,7 @@ export const ModelContextMenuMixin = {
}, },
// Download example images method // Download example images method
async downloadExampleImages() { async downloadExampleImages(force = false) {
const modelHash = this.currentCard.dataset.sha256; const modelHash = this.currentCard.dataset.sha256;
if (!modelHash) { if (!modelHash) {
showToast('toast.contextMenu.missingHash', {}, 'error'); showToast('toast.contextMenu.missingHash', {}, 'error');
@@ -387,7 +390,7 @@ export const ModelContextMenuMixin = {
try { try {
const apiClient = getModelApiClient(); const apiClient = getModelApiClient();
await apiClient.downloadExampleImages([modelHash]); await apiClient.downloadExampleImages([modelHash], null, { force });
} catch (error) { } catch (error) {
console.error('Error downloading example images:', error); console.error('Error downloading example images:', error);
} }

View File

@@ -33,6 +33,7 @@
<!-- Media / Preview --> <!-- Media / Preview -->
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div> <div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }}</div> <div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }}</div>
<div class="context-menu-item" data-action="download-examples-force"><i class="fas fa-redo-alt"></i> {{ t('loras.contextMenu.reprocessExamples') }}</div>
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div> <div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>
<div class="context-menu-separator menu-section-break"></div> <div class="context-menu-separator menu-section-break"></div>
<!-- Attributes --> <!-- Attributes -->

View File

@@ -47,6 +47,9 @@
<div class="context-menu-item" data-action="download-examples"> <div class="context-menu-item" data-action="download-examples">
<i class="fas fa-download"></i> <span>{{ t('loras.contextMenu.downloadExamples') }}</span> <i class="fas fa-download"></i> <span>{{ t('loras.contextMenu.downloadExamples') }}</span>
</div> </div>
<div class="context-menu-item" data-action="download-examples-force">
<i class="fas fa-redo-alt"></i> <span>{{ t('loras.contextMenu.reprocessExamples') }}</span>
</div>
<div class="context-menu-item" data-action="replace-preview"> <div class="context-menu-item" data-action="replace-preview">
<i class="fas fa-image"></i> <span>{{ t('loras.contextMenu.replacePreview') }}</span> <i class="fas fa-image"></i> <span>{{ t('loras.contextMenu.replacePreview') }}</span>
</div> </div>
@@ -136,8 +139,11 @@
</div> </div>
<div class="context-menu-section" data-section="download"> <div class="context-menu-section" data-section="download">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.download') }}</div> <div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.download') }}</div>
<div class="context-menu-item" data-action="download-missing-example-images">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingExamples') }}</span>
</div>
<div class="context-menu-item" data-action="download-example-images"> <div class="context-menu-item" data-action="download-example-images">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadExamples') }}</span> <i class="fas fa-redo-alt"></i> <span>{{ t('loras.bulkOperations.reprocessExamples') }}</span>
</div> </div>
<div class="context-menu-item" data-action="download-missing-loras"> <div class="context-menu-item" data-action="download-missing-loras">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span> <i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>

View File

@@ -33,6 +33,7 @@
<!-- Media / Preview --> <!-- Media / Preview -->
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div> <div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }}</div> <div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }}</div>
<div class="context-menu-item" data-action="download-examples-force"><i class="fas fa-redo-alt"></i> {{ t('loras.contextMenu.reprocessExamples') }}</div>
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div> <div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>
<div class="context-menu-separator menu-section-break"></div> <div class="context-menu-separator menu-section-break"></div>
<!-- Attributes --> <!-- Attributes -->

View File

@@ -2155,4 +2155,31 @@ describe('Interaction-level regression coverage', () => {
excludedItem.dispatchEvent(new Event('click', { bubbles: true })); excludedItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(window.pageControls.enterExcludedView).toHaveBeenCalledTimes(1); expect(window.pageControls.enterExcludedView).toHaveBeenCalledTimes(1);
}); });
it('routes single-model example downloads to missing-only and force paths', async () => {
document.body.innerHTML = `
<div id="loraContextMenu" class="context-menu">
<div class="context-menu-item" data-action="download-examples"></div>
<div class="context-menu-item" data-action="download-examples-force"></div>
</div>
`;
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
const contextMenu = new LoraContextMenu();
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = '/models/test.safetensors';
card.dataset.sha256 = 'abc123hash';
document.body.appendChild(card);
contextMenu.showMenu(100, 100, card);
document.querySelector('[data-action="download-examples"]').dispatchEvent(new Event('click', { bubbles: true }));
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: false });
contextMenu.showMenu(100, 100, card);
document.querySelector('[data-action="download-examples-force"]').dispatchEvent(new Event('click', { bubbles: true }));
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: true });
});
}); });

View File

@@ -529,7 +529,8 @@ async def test_not_found_example_images_are_cleaned(
model_dir = images_root / model_hash model_dir = images_root / model_hash
model_dir.mkdir(parents=True, exist_ok=True) model_dir.mkdir(parents=True, exist_ok=True)
(model_dir / "image_0.png").write_bytes(b"first") # Pre-existing file collides with the valid image index (1) so the
# pre-download existence check must skip it without a network request
(model_dir / "image_1.png").write_bytes(b"second") (model_dir / "image_1.png").write_bytes(b"second")
async def fake_process_local_examples(*_args, **_kwargs): async def fake_process_local_examples(*_args, **_kwargs):
@@ -608,11 +609,188 @@ async def test_not_found_example_images_are_cleaned(
] ]
files = sorted(p.name for p in model_dir.iterdir()) files = sorted(p.name for p in model_dir.iterdir())
assert files == ["image_0.png", "image_1.png"] assert files == ["image_1.png"]
assert (model_dir / "image_0.png").read_bytes() == b"first"
assert (model_dir / "image_1.png").read_bytes() == b"second" assert (model_dir / "image_1.png").read_bytes() == b"second"
async def test_failed_models_retried_when_explicitly_targeted(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
settings_manager,
):
ws_manager = RecordingWebSocketManager()
manager = download_module.DownloadManager(ws_manager=ws_manager)
images_root = tmp_path / "examples"
monkeypatch.setitem(settings_manager.settings, "example_images_path", str(images_root))
model_hash = "a" * 64
model_path = tmp_path / "model.safetensors"
model_path.write_text("data", encoding="utf-8")
model_metadata = {
"sha256": model_hash,
"model_name": "Failed Example",
"file_path": str(model_path),
"file_name": "model.safetensors",
"civitai": {"images": [{"url": "https://example.com/valid.png"}]},
}
scanner = StubScanner([model_metadata.copy()])
_patch_scanner(monkeypatch, scanner)
# Persist a previous failure so the skip path is exercised
images_root.mkdir(parents=True, exist_ok=True)
(images_root / ".download_progress.json").write_text(
json.dumps(
{
"failed_models": [model_hash],
"processed_models": [],
"rate_limited_models": [],
}
),
encoding="utf-8",
)
async def fake_process_local_examples(*_args, **_kwargs):
return False
async def fake_get_updated_model(model_hash_arg, _scanner):
return model_metadata
class DownloaderStub:
def __init__(self):
self.calls: list[str] = []
async def download_to_memory(self, url, *_args, **_kwargs):
self.calls.append(url)
return True, b"\x89PNG\r\n\x1a\n", {"content-type": "image/png"}
downloader = DownloaderStub()
async def fake_get_downloader():
return downloader
monkeypatch.setattr(
download_module.ExampleImagesProcessor,
"process_local_examples",
staticmethod(fake_process_local_examples),
)
monkeypatch.setattr(
download_module.MetadataUpdater,
"get_updated_model",
staticmethod(fake_get_updated_model),
)
monkeypatch.setattr(download_module, "get_downloader", fake_get_downloader)
# Without explicit hashes the previously failed model is skipped
skipped_manager = download_module.DownloadManager(ws_manager=RecordingWebSocketManager())
result = await skipped_manager.start_download({"model_types": ["lora"], "delay": 0})
assert result["success"] is True
if skipped_manager._download_task is not None:
await asyncio.wait_for(skipped_manager._download_task, timeout=1)
assert downloader.calls == []
# With explicit hashes the previously failed model is retried and cleared
result = await manager.start_download(
{"model_types": ["lora"], "delay": 0, "model_hashes": [model_hash]}
)
assert result["success"] is True
if manager._download_task is not None:
await asyncio.wait_for(manager._download_task, timeout=1)
assert downloader.calls == ["https://example.com/valid.png"]
assert manager._progress["failed_models"] == set()
assert model_hash in manager._progress["processed_models"]
async def test_explicit_targets_fill_partial_example_gaps(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
settings_manager,
):
ws_manager = RecordingWebSocketManager()
images_root = tmp_path / "examples"
monkeypatch.setitem(settings_manager.settings, "example_images_path", str(images_root))
model_hash = "b" * 64
model_path = tmp_path / "model.safetensors"
model_path.write_text("data", encoding="utf-8")
model_metadata = {
"sha256": model_hash,
"model_name": "Partial Example",
"file_path": str(model_path),
"file_name": "model.safetensors",
"civitai": {
"images": [
{"url": "https://example.com/first.png"},
{"url": "https://example.com/second.png"},
]
},
}
scanner = StubScanner([model_metadata.copy()])
_patch_scanner(monkeypatch, scanner)
# Simulate a partially populated folder: index 0 already downloaded
model_dir = images_root / model_hash
model_dir.mkdir(parents=True, exist_ok=True)
(model_dir / "image_0.png").write_bytes(b"existing")
async def fake_process_local_examples(*_args, **_kwargs):
return False
async def fake_get_updated_model(model_hash_arg, _scanner):
return model_metadata
class DownloaderStub:
def __init__(self):
self.calls: list[str] = []
async def download_to_memory(self, url, *_args, **_kwargs):
self.calls.append(url)
return True, b"\x89PNG\r\n\x1a\n", {"content-type": "image/png"}
downloader = DownloaderStub()
async def fake_get_downloader():
return downloader
monkeypatch.setattr(
download_module.ExampleImagesProcessor,
"process_local_examples",
staticmethod(fake_process_local_examples),
)
monkeypatch.setattr(
download_module.MetadataUpdater,
"get_updated_model",
staticmethod(fake_get_updated_model),
)
monkeypatch.setattr(download_module, "get_downloader", fake_get_downloader)
# Untargeted run treats the populated folder as done
untargeted = download_module.DownloadManager(ws_manager=RecordingWebSocketManager())
result = await untargeted.start_download({"model_types": ["lora"], "delay": 0})
assert result["success"] is True
if untargeted._download_task is not None:
await asyncio.wait_for(untargeted._download_task, timeout=1)
assert downloader.calls == []
# Explicitly targeted run fills only the missing index, skipping the
# existing file without a network request
targeted = download_module.DownloadManager(ws_manager=ws_manager)
result = await targeted.start_download(
{"model_types": ["lora"], "delay": 0, "model_hashes": [model_hash]}
)
assert result["success"] is True
if targeted._download_task is not None:
await asyncio.wait_for(targeted._download_task, timeout=1)
assert downloader.calls == ["https://example.com/second.png"]
assert (model_dir / "image_1.png").exists()
assert (model_dir / "image_0.png").read_bytes() == b"existing"
@pytest.fixture @pytest.fixture
def settings_manager(): def settings_manager():
return get_settings_manager() return get_settings_manager()

View File

@@ -63,7 +63,7 @@ async def test_start_download_bootstraps_progress_and_task(
release = asyncio.Event() release = asyncio.Event()
async def fake_download( async def fake_download(
self, output_dir, optimize, model_types, delay, library_name, force=False self, output_dir, optimize, model_types, delay, library_name, force=False, model_hashes=None
): ):
started.set() started.set()
await release.wait() await release.wait()
@@ -93,6 +93,44 @@ async def test_start_download_bootstraps_progress_and_task(
assert manager._progress["status"] == "completed" assert manager._progress["status"] == "completed"
async def test_start_download_forwards_model_hashes(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path)
settings_manager.settings["libraries"] = {"default": {}}
settings_manager.settings["active_library"] = "default"
manager = download_module.DownloadManager(ws_manager=RecordingWebSocketManager())
received: Dict[str, Any] = {}
async def fake_download(
self, output_dir, optimize, model_types, delay, library_name, force=False, model_hashes=None
):
received["model_hashes"] = model_hashes
async with self._state_lock:
self._is_downloading = False
self._download_task = None
self._progress["status"] = "completed"
monkeypatch.setattr(
download_module.DownloadManager,
"_download_all_example_images",
fake_download,
)
result = await manager.start_download(
{"model_types": ["lora"], "delay": 0, "model_hashes": ["abc123", "def456"]}
)
assert result["success"] is True
task = manager._download_task
assert task is not None
await asyncio.wait_for(task, timeout=1)
assert received["model_hashes"] == ["abc123", "def456"]
async def test_pause_and_resume_flow(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: async def test_pause_and_resume_flow(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path) settings_manager.settings["example_images_path"] = str(tmp_path)

View File

@@ -100,6 +100,54 @@ def test_get_file_extension_media_type_hint_low_priority() -> None:
assert ext == ".mp4" assert ext == ".mp4"
def test_example_image_file_exists_checks_plausible_extensions(tmp_path) -> None:
proc = processor_module.ExampleImagesProcessor
assert proc._example_image_file_exists(str(tmp_path), 0) is False
Path(tmp_path, "image_0.webp").write_bytes(b"x")
assert proc._example_image_file_exists(str(tmp_path), 0) is True
assert proc._example_image_file_exists(str(tmp_path), 1) is False
def test_example_image_file_exists_video_hint_only_checks_video_extensions(tmp_path) -> None:
proc = processor_module.ExampleImagesProcessor
Path(tmp_path, "image_2.jpg").write_bytes(b"x")
# An existing image file must not satisfy a video-hinted lookup
assert proc._example_image_file_exists(str(tmp_path), 2, "video") is False
Path(tmp_path, "image_2.mp4").write_bytes(b"x")
assert proc._example_image_file_exists(str(tmp_path), 2, "video") is True
async def test_download_model_images_with_tracking_skips_existing_files(tmp_path) -> None:
proc = processor_module.ExampleImagesProcessor
images = [
{"url": "https://image.civitai.com/a/b", "type": "image"},
{"url": "https://image.civitai.com/c/d", "type": "image"},
]
Path(tmp_path, "image_0.jpg").write_bytes(b"existing")
class RecordingDownloader:
def __init__(self) -> None:
self.calls: list[str] = []
async def download_to_memory(self, url, use_auth=False, return_headers=False):
self.calls.append(url)
return True, b"\xff\xd8\xff" + b"data", {}
downloader = RecordingDownloader()
success, is_stale, failed, rate_limited = await proc.download_model_images_with_tracking(
"hash", "model", images, str(tmp_path), False, downloader
)
assert success is True
assert is_stale is False
assert failed == []
assert rate_limited == []
# Only the missing image is requested; the existing one is skipped without a network call
assert len(downloader.calls) == 1
assert "c/d" in downloader.calls[0]
assert Path(tmp_path, "image_1.jpg").exists()
class StubScanner: class StubScanner:
def __init__(self, models: list[Dict[str, Any]]) -> None: def __init__(self, models: list[Dict[str, Any]]) -> None:
self._cache = SimpleNamespace(raw_data=models) self._cache = SimpleNamespace(raw_data=models)