mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-09 02:52:12 -03:00
feat(metadata): skip models on offline roots during bulk fetch (#1108)
Bulk metadata refresh picked candidates purely from cache state, so the entries the scoped scan deliberately keeps for unreachable drives were fetched anyway — burning CivitAI rate limit on sidecar/preview writes that cannot land. - Filter the fetch queue by root reachability (live check via describe_model_roots), covering both startup-offline and mid-session drive-off; probing failures degrade to no filtering - Report offline_skipped / offline_roots in the WS terminal payloads, the HTTP result and the completion message - Show a "Drive Offline" stat card in the fetch summary modal when any model was skipped this way
This commit is contained in:
@@ -2130,6 +2130,7 @@
|
|||||||
"statSuccess": "Erfolgreich",
|
"statSuccess": "Erfolgreich",
|
||||||
"statFailed": "Fehlgeschlagen",
|
"statFailed": "Fehlgeschlagen",
|
||||||
"statSkipped": "Übersprungen",
|
"statSkipped": "Übersprungen",
|
||||||
|
"statOffline": "[TODO: Translate] Drive Offline",
|
||||||
"statTotal": "Gesamt geprüft",
|
"statTotal": "Gesamt geprüft",
|
||||||
"statDuration": "Dauer",
|
"statDuration": "Dauer",
|
||||||
"successMessage": "Alle {count} {type}s erfolgreich aktualisiert!",
|
"successMessage": "Alle {count} {type}s erfolgreich aktualisiert!",
|
||||||
|
|||||||
@@ -2130,6 +2130,7 @@
|
|||||||
"statSuccess": "Success",
|
"statSuccess": "Success",
|
||||||
"statFailed": "Failed",
|
"statFailed": "Failed",
|
||||||
"statSkipped": "Skipped",
|
"statSkipped": "Skipped",
|
||||||
|
"statOffline": "Drive Offline",
|
||||||
"statTotal": "Total Scanned",
|
"statTotal": "Total Scanned",
|
||||||
"statDuration": "Duration",
|
"statDuration": "Duration",
|
||||||
"successMessage": "All {count} {type}s updated successfully!",
|
"successMessage": "All {count} {type}s updated successfully!",
|
||||||
|
|||||||
@@ -2130,6 +2130,7 @@
|
|||||||
"statSuccess": "Éxito",
|
"statSuccess": "Éxito",
|
||||||
"statFailed": "Fallido",
|
"statFailed": "Fallido",
|
||||||
"statSkipped": "Omitido",
|
"statSkipped": "Omitido",
|
||||||
|
"statOffline": "[TODO: Translate] Drive Offline",
|
||||||
"statTotal": "Total escaneado",
|
"statTotal": "Total escaneado",
|
||||||
"statDuration": "Duración",
|
"statDuration": "Duración",
|
||||||
"successMessage": "¡Todos los {count} {type}s actualizados correctamente!",
|
"successMessage": "¡Todos los {count} {type}s actualizados correctamente!",
|
||||||
|
|||||||
@@ -2130,6 +2130,7 @@
|
|||||||
"statSuccess": "Réussi",
|
"statSuccess": "Réussi",
|
||||||
"statFailed": "Échoué",
|
"statFailed": "Échoué",
|
||||||
"statSkipped": "Ignoré",
|
"statSkipped": "Ignoré",
|
||||||
|
"statOffline": "[TODO: Translate] Drive Offline",
|
||||||
"statTotal": "Total scanné",
|
"statTotal": "Total scanné",
|
||||||
"statDuration": "Durée",
|
"statDuration": "Durée",
|
||||||
"successMessage": "Tous les {count} {type}s mis à jour avec succès !",
|
"successMessage": "Tous les {count} {type}s mis à jour avec succès !",
|
||||||
|
|||||||
@@ -2130,6 +2130,7 @@
|
|||||||
"statSuccess": "הצלחה",
|
"statSuccess": "הצלחה",
|
||||||
"statFailed": "נכשל",
|
"statFailed": "נכשל",
|
||||||
"statSkipped": "דולג",
|
"statSkipped": "דולג",
|
||||||
|
"statOffline": "[TODO: Translate] Drive Offline",
|
||||||
"statTotal": "סה\"כ נסרק",
|
"statTotal": "סה\"כ נסרק",
|
||||||
"statDuration": "משך",
|
"statDuration": "משך",
|
||||||
"successMessage": "כל {count} {type}s עודכנו בהצלחה!",
|
"successMessage": "כל {count} {type}s עודכנו בהצלחה!",
|
||||||
|
|||||||
@@ -2130,6 +2130,7 @@
|
|||||||
"statSuccess": "成功",
|
"statSuccess": "成功",
|
||||||
"statFailed": "失敗",
|
"statFailed": "失敗",
|
||||||
"statSkipped": "スキップ",
|
"statSkipped": "スキップ",
|
||||||
|
"statOffline": "[TODO: Translate] Drive Offline",
|
||||||
"statTotal": "スキャン合計",
|
"statTotal": "スキャン合計",
|
||||||
"statDuration": "所要時間",
|
"statDuration": "所要時間",
|
||||||
"successMessage": "すべての{count}件の{type}を正常に更新しました",
|
"successMessage": "すべての{count}件の{type}を正常に更新しました",
|
||||||
|
|||||||
@@ -2130,6 +2130,7 @@
|
|||||||
"statSuccess": "성공",
|
"statSuccess": "성공",
|
||||||
"statFailed": "실패",
|
"statFailed": "실패",
|
||||||
"statSkipped": "건너뜀",
|
"statSkipped": "건너뜀",
|
||||||
|
"statOffline": "[TODO: Translate] Drive Offline",
|
||||||
"statTotal": "총 스캔",
|
"statTotal": "총 스캔",
|
||||||
"statDuration": "소요 시간",
|
"statDuration": "소요 시간",
|
||||||
"successMessage": "모든 {count}개 {type}이(가) 성공적으로 업데이트되었습니다",
|
"successMessage": "모든 {count}개 {type}이(가) 성공적으로 업데이트되었습니다",
|
||||||
|
|||||||
@@ -2130,6 +2130,7 @@
|
|||||||
"statSuccess": "Успешно",
|
"statSuccess": "Успешно",
|
||||||
"statFailed": "Ошибка",
|
"statFailed": "Ошибка",
|
||||||
"statSkipped": "Пропущено",
|
"statSkipped": "Пропущено",
|
||||||
|
"statOffline": "[TODO: Translate] Drive Offline",
|
||||||
"statTotal": "Всего проверено",
|
"statTotal": "Всего проверено",
|
||||||
"statDuration": "Длительность",
|
"statDuration": "Длительность",
|
||||||
"successMessage": "Все {count} {type}s успешно обновлены",
|
"successMessage": "Все {count} {type}s успешно обновлены",
|
||||||
|
|||||||
@@ -2130,6 +2130,7 @@
|
|||||||
"statSuccess": "成功",
|
"statSuccess": "成功",
|
||||||
"statFailed": "失败",
|
"statFailed": "失败",
|
||||||
"statSkipped": "已跳过",
|
"statSkipped": "已跳过",
|
||||||
|
"statOffline": "[TODO: Translate] Drive Offline",
|
||||||
"statTotal": "总计扫描",
|
"statTotal": "总计扫描",
|
||||||
"statDuration": "耗时",
|
"statDuration": "耗时",
|
||||||
"successMessage": "全部 {count} 个 {type} 更新成功!",
|
"successMessage": "全部 {count} 个 {type} 更新成功!",
|
||||||
|
|||||||
@@ -2130,6 +2130,7 @@
|
|||||||
"statSuccess": "成功",
|
"statSuccess": "成功",
|
||||||
"statFailed": "失敗",
|
"statFailed": "失敗",
|
||||||
"statSkipped": "已跳過",
|
"statSkipped": "已跳過",
|
||||||
|
"statOffline": "[TODO: Translate] Drive Offline",
|
||||||
"statTotal": "總計掃描",
|
"statTotal": "總計掃描",
|
||||||
"statDuration": "耗時",
|
"statDuration": "耗時",
|
||||||
"successMessage": "全部 {count} 個 {type} 更新成功!",
|
"successMessage": "全部 {count} 個 {type} 更新成功!",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import time
|
|||||||
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
||||||
|
|
||||||
from ..metadata_sync_service import MetadataSyncService
|
from ..metadata_sync_service import MetadataSyncService
|
||||||
|
from ..model_scanner import _normalized_root_prefix, _path_matches_prefix
|
||||||
from ..model_sources import has_external_source
|
from ..model_sources import has_external_source
|
||||||
from ...utils.metadata_manager import MetadataManager
|
from ...utils.metadata_manager import MetadataManager
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ class BulkMetadataRefreshUseCase:
|
|||||||
|
|
||||||
enable_metadata_archive_db = self._settings.get("enable_metadata_archive_db", False)
|
enable_metadata_archive_db = self._settings.get("enable_metadata_archive_db", False)
|
||||||
skip_paths = self._settings.get("metadata_refresh_skip_paths", [])
|
skip_paths = self._settings.get("metadata_refresh_skip_paths", [])
|
||||||
to_process: Sequence[Dict[str, Any]] = [
|
eligible: Sequence[Dict[str, Any]] = [
|
||||||
model
|
model
|
||||||
for model in cache.raw_data
|
for model in cache.raw_data
|
||||||
if not model.get("skip_metadata_refresh", False)
|
if not model.get("skip_metadata_refresh", False)
|
||||||
@@ -68,6 +69,26 @@ class BulkMetadataRefreshUseCase:
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Models on a drive that cannot be read right now stay in the cache
|
||||||
|
# (scoped scan keeps them), but fetching their metadata would only
|
||||||
|
# spend CivitAI rate limit on sidecar/preview writes that cannot land.
|
||||||
|
offline_roots = self._offline_root_details()
|
||||||
|
if offline_roots:
|
||||||
|
to_process = [
|
||||||
|
model
|
||||||
|
for model in eligible
|
||||||
|
if not self._is_under_offline_root(model.get("file_path", ""), offline_roots)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
to_process = list(eligible)
|
||||||
|
offline_skipped = len(eligible) - len(to_process)
|
||||||
|
if offline_skipped:
|
||||||
|
self._logger.info(
|
||||||
|
"Bulk metadata refresh: %d model(s) skipped, drive offline: %s",
|
||||||
|
offline_skipped,
|
||||||
|
", ".join(root["label"] for root in offline_roots),
|
||||||
|
)
|
||||||
|
|
||||||
total_to_process = len(to_process)
|
total_to_process = len(to_process)
|
||||||
initial_skipped = total_models - total_to_process # models excluded from fetch queue
|
initial_skipped = total_models - total_to_process # models excluded from fetch queue
|
||||||
processed = 0
|
processed = 0
|
||||||
@@ -90,14 +111,21 @@ class BulkMetadataRefreshUseCase:
|
|||||||
"success": success,
|
"success": success,
|
||||||
"failure_count": len(failures),
|
"failure_count": len(failures),
|
||||||
"skipped_count": skipped_count,
|
"skipped_count": skipped_count,
|
||||||
|
"offline_skipped": offline_skipped,
|
||||||
"handled": handled_count,
|
"handled": handled_count,
|
||||||
"elapsed_seconds": int(time.monotonic() - start_time),
|
"elapsed_seconds": int(time.monotonic() - start_time),
|
||||||
}
|
}
|
||||||
# Only include full failure details in terminal emits (completed,
|
# Only include full failure details in terminal emits (completed,
|
||||||
# cancelled, rate_limited) to avoid serializing the list on every
|
# cancelled, rate_limited) to avoid serializing the list on every
|
||||||
# per-model progress update.
|
# per-model progress update.
|
||||||
if failures and status in ("completed", "cancelled", "rate_limited"):
|
if status in ("completed", "cancelled", "rate_limited"):
|
||||||
payload["failures"] = failures
|
if failures:
|
||||||
|
payload["failures"] = failures
|
||||||
|
if offline_roots:
|
||||||
|
payload["offline_roots"] = [
|
||||||
|
{"path": root["path"], "label": root["label"]}
|
||||||
|
for root in offline_roots
|
||||||
|
]
|
||||||
payload.update(extra)
|
payload.update(extra)
|
||||||
await progress_callback.on_progress(payload)
|
await progress_callback.on_progress(payload)
|
||||||
|
|
||||||
@@ -110,7 +138,7 @@ class BulkMetadataRefreshUseCase:
|
|||||||
if self._service.scanner.is_cancelled():
|
if self._service.scanner.is_cancelled():
|
||||||
self._logger.info("Bulk metadata refresh cancelled by user")
|
self._logger.info("Bulk metadata refresh cancelled by user")
|
||||||
await emit("cancelled", processed=processed, success=success)
|
await emit("cancelled", processed=processed, success=success)
|
||||||
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
|
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "offline_skipped": offline_skipped, "elapsed_seconds": int(time.monotonic() - start_time)}
|
||||||
try:
|
try:
|
||||||
original_name = model.get("model_name")
|
original_name = model.get("model_name")
|
||||||
|
|
||||||
@@ -202,6 +230,7 @@ class BulkMetadataRefreshUseCase:
|
|||||||
"failures": failures,
|
"failures": failures,
|
||||||
"failure_count": len(failures),
|
"failure_count": len(failures),
|
||||||
"skipped_count": skipped_count,
|
"skipped_count": skipped_count,
|
||||||
|
"offline_skipped": offline_skipped,
|
||||||
"elapsed_seconds": int(time.monotonic() - start_time),
|
"elapsed_seconds": int(time.monotonic() - start_time),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,8 +266,58 @@ class BulkMetadataRefreshUseCase:
|
|||||||
"Successfully updated "
|
"Successfully updated "
|
||||||
f"{success} of {processed} processed {self._service.model_type}s (total: {total_models})"
|
f"{success} of {processed} processed {self._service.model_type}s (total: {total_models})"
|
||||||
)
|
)
|
||||||
|
if offline_skipped:
|
||||||
|
message += (
|
||||||
|
f"; {offline_skipped} skipped, drive offline ("
|
||||||
|
+ ", ".join(root["label"] for root in offline_roots)
|
||||||
|
+ ")"
|
||||||
|
)
|
||||||
|
|
||||||
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
|
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "offline_skipped": offline_skipped, "elapsed_seconds": int(time.monotonic() - start_time)}
|
||||||
|
|
||||||
|
def _offline_root_details(self) -> List[Dict[str, str]]:
|
||||||
|
"""Configured roots whose directory cannot be read right now.
|
||||||
|
|
||||||
|
Uses the scanner's root description (live ``os.path.exists`` per root),
|
||||||
|
so both a drive switched off at startup and one unplugged mid-session
|
||||||
|
are covered. Root probing must never break a fetch: any failure yields
|
||||||
|
an empty list, which disables the offline filter.
|
||||||
|
"""
|
||||||
|
describe = getattr(self._service.scanner, "describe_model_roots", None)
|
||||||
|
if describe is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
details = describe()
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
self._logger.debug("Failed to describe model roots: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
offline: List[Dict[str, str]] = []
|
||||||
|
for detail in details:
|
||||||
|
if not isinstance(detail, dict) or detail.get("reachable", True):
|
||||||
|
continue
|
||||||
|
path = detail.get("path") or ""
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
offline.append(
|
||||||
|
{
|
||||||
|
"path": path,
|
||||||
|
"label": detail.get("label") or path,
|
||||||
|
"prefix": _normalized_root_prefix(path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return offline
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_under_offline_root(
|
||||||
|
file_path: str, offline_roots: Sequence[Dict[str, str]]
|
||||||
|
) -> bool:
|
||||||
|
"""Match a cached business path against offline root prefixes."""
|
||||||
|
if not file_path:
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
_path_matches_prefix(file_path, root["prefix"]) for root in offline_roots
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool:
|
def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool:
|
||||||
|
|||||||
@@ -55,6 +55,10 @@
|
|||||||
border-left-color: var(--color-warning);
|
border-left-color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-card-offline {
|
||||||
|
border-left-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.stat-card-total {
|
.stat-card-total {
|
||||||
border-left-color: var(--lora-border);
|
border-left-color: var(--lora-border);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -898,7 +898,9 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_showFailureDetailsModal(data) {
|
_showFailureDetailsModal(data) {
|
||||||
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
|
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds, offline_skipped = 0, offline_roots = [] } = data;
|
||||||
|
|
||||||
|
const offlineRootNames = offline_roots.map((root) => root.label || root.path || String(root)).join(', ');
|
||||||
|
|
||||||
// Build failure list HTML
|
// Build failure list HTML
|
||||||
const failureRows = failures.map((f, i) =>
|
const failureRows = failures.map((f, i) =>
|
||||||
@@ -935,6 +937,13 @@ export class BaseModelApiClient {
|
|||||||
<span class="stat-card-value">${skipped_count}</span>
|
<span class="stat-card-value">${skipped_count}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${offline_skipped > 0 ? `
|
||||||
|
<div class="stat-card stat-card-offline" title="${this._escapeHtml(offlineRootNames)}">
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statOffline', {}, 'Drive Offline')}</span>
|
||||||
|
<span class="stat-card-value">${offline_skipped}</span>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
<div class="stat-card stat-card-total">
|
<div class="stat-card stat-card-total">
|
||||||
<div class="stat-card-body">
|
<div class="stat-card-body">
|
||||||
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statTotal', {}, 'Total Scanned')}</span>
|
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statTotal', {}, 'Total Scanned')}</span>
|
||||||
|
|||||||
@@ -402,3 +402,138 @@ async def test_model_without_hash_skipped(use_case, mock_service, mock_metadata_
|
|||||||
|
|
||||||
assert result["processed"] == 1
|
assert result["processed"] == 1
|
||||||
assert result["updated"] == 0
|
assert result["updated"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _offline_scanner_mock(scanner):
|
||||||
|
"""Point the scanner mock at one offline and one online root."""
|
||||||
|
scanner.describe_model_roots = MagicMock(
|
||||||
|
return_value=[
|
||||||
|
{"path": "/mnt/offline_loras", "label": "offline_loras", "reachable": False, "available": False, "models": 1},
|
||||||
|
{"path": "/models", "label": "models", "reachable": True, "available": True, "models": 1},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch.object(metadata_manager.MetadataManager, "hydrate_model_data")
|
||||||
|
async def test_fetch_skips_models_on_offline_root(mock_hydrate, use_case, mock_service, mock_metadata_sync):
|
||||||
|
"""Models under an unreachable root are excluded from the fetch queue."""
|
||||||
|
mock_hydrate.return_value = None
|
||||||
|
_offline_scanner_mock(mock_service.scanner)
|
||||||
|
|
||||||
|
offline_model = {
|
||||||
|
"file_path": "/mnt/offline_loras/model.safetensors",
|
||||||
|
"sha256": "hash_offline",
|
||||||
|
"model_name": "Offline Model",
|
||||||
|
"civitai": {},
|
||||||
|
"from_civitai": False,
|
||||||
|
"civitai_deleted": False,
|
||||||
|
}
|
||||||
|
online_model = {
|
||||||
|
"file_path": "/models/model.safetensors",
|
||||||
|
"sha256": "hash_online",
|
||||||
|
"model_name": "Online Model",
|
||||||
|
"civitai": {},
|
||||||
|
"from_civitai": False,
|
||||||
|
"civitai_deleted": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
cache = SimpleNamespace(raw_data=[offline_model, online_model], resort=AsyncMock())
|
||||||
|
mock_service.scanner.get_cached_data.return_value = cache
|
||||||
|
|
||||||
|
reporter = MockProgressReporter()
|
||||||
|
result = await use_case.execute(progress_callback=reporter)
|
||||||
|
|
||||||
|
# Only the online model is fetched
|
||||||
|
mock_metadata_sync.fetch_and_update_model.assert_called_once()
|
||||||
|
call_args = mock_metadata_sync.fetch_and_update_model.call_args[1]
|
||||||
|
assert call_args["file_path"] == "/models/model.safetensors"
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["processed"] == 1
|
||||||
|
assert result["offline_skipped"] == 1
|
||||||
|
# The offline model still counts as excluded from the queue
|
||||||
|
assert result["skipped_count"] == 1
|
||||||
|
|
||||||
|
completed_calls = [c for c in reporter.progress_calls if c["status"] == "completed"]
|
||||||
|
assert completed_calls[0]["offline_skipped"] == 1
|
||||||
|
assert completed_calls[0]["offline_roots"] == [
|
||||||
|
{"path": "/mnt/offline_loras", "label": "offline_loras"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch.object(metadata_manager.MetadataManager, "hydrate_model_data")
|
||||||
|
async def test_fetch_offline_filter_only_catches_offline_prefix(mock_hydrate, use_case, mock_service, mock_metadata_sync):
|
||||||
|
"""A sibling path that merely shares a prefix string is not excluded."""
|
||||||
|
mock_hydrate.return_value = None
|
||||||
|
_offline_scanner_mock(mock_service.scanner)
|
||||||
|
|
||||||
|
# "/mnt/offline_loras_backup/..." must NOT match the "/mnt/offline_loras/" prefix
|
||||||
|
sibling_model = {
|
||||||
|
"file_path": "/mnt/offline_loras_backup/model.safetensors",
|
||||||
|
"sha256": "hash_sibling",
|
||||||
|
"model_name": "Sibling Model",
|
||||||
|
"civitai": {},
|
||||||
|
"from_civitai": False,
|
||||||
|
"civitai_deleted": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
cache = SimpleNamespace(raw_data=[sibling_model], resort=AsyncMock())
|
||||||
|
mock_service.scanner.get_cached_data.return_value = cache
|
||||||
|
|
||||||
|
result = await use_case.execute()
|
||||||
|
|
||||||
|
mock_metadata_sync.fetch_and_update_model.assert_called_once()
|
||||||
|
assert result["offline_skipped"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch.object(metadata_manager.MetadataManager, "hydrate_model_data")
|
||||||
|
async def test_fetch_proceeds_unfiltered_without_root_description(mock_hydrate, use_case, mock_service, mock_metadata_sync):
|
||||||
|
"""Scanners without describe_model_roots keep the previous behaviour."""
|
||||||
|
mock_hydrate.return_value = None
|
||||||
|
del mock_service.scanner.describe_model_roots
|
||||||
|
|
||||||
|
model = {
|
||||||
|
"file_path": "/mnt/offline_loras/model.safetensors",
|
||||||
|
"sha256": "hash123",
|
||||||
|
"model_name": "Test Model",
|
||||||
|
"civitai": {},
|
||||||
|
"from_civitai": False,
|
||||||
|
"civitai_deleted": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
cache = SimpleNamespace(raw_data=[model], resort=AsyncMock())
|
||||||
|
mock_service.scanner.get_cached_data.return_value = cache
|
||||||
|
|
||||||
|
result = await use_case.execute()
|
||||||
|
|
||||||
|
mock_metadata_sync.fetch_and_update_model.assert_called_once()
|
||||||
|
assert result["offline_skipped"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch.object(metadata_manager.MetadataManager, "hydrate_model_data")
|
||||||
|
async def test_fetch_proceeds_unfiltered_when_root_description_fails(mock_hydrate, use_case, mock_service, mock_metadata_sync):
|
||||||
|
"""A failing describe_model_roots must not break the fetch."""
|
||||||
|
mock_hydrate.return_value = None
|
||||||
|
mock_service.scanner.describe_model_roots = MagicMock(side_effect=RuntimeError("boom"))
|
||||||
|
|
||||||
|
model = {
|
||||||
|
"file_path": "/models/model.safetensors",
|
||||||
|
"sha256": "hash123",
|
||||||
|
"model_name": "Test Model",
|
||||||
|
"civitai": {},
|
||||||
|
"from_civitai": False,
|
||||||
|
"civitai_deleted": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
cache = SimpleNamespace(raw_data=[model], resort=AsyncMock())
|
||||||
|
mock_service.scanner.get_cached_data.return_value = cache
|
||||||
|
|
||||||
|
result = await use_case.execute()
|
||||||
|
|
||||||
|
mock_metadata_sync.fetch_and_update_model.assert_called_once()
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["offline_skipped"] == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user