mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-20 04:21:27 -03:00
feat(download): per-file download status and multi-file selection (#1058)
This commit is contained in:
+2331
-2327
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -1244,11 +1244,13 @@
|
||||
"downloaded": "Downloaded",
|
||||
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
||||
"alreadyInLibrary": "Already in Library",
|
||||
"partiallyDownloaded": "Partially downloaded",
|
||||
"autoOrganizedPath": "[Auto-organized by path template]",
|
||||
"fileSelection": {
|
||||
"title": "Select File Format",
|
||||
"files": "files",
|
||||
"select": "Select File"
|
||||
"select": "Select File",
|
||||
"inLibrary": "In Library"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Invalid Civitai URL format",
|
||||
@@ -1594,6 +1596,7 @@
|
||||
"actions": {
|
||||
"download": "Download",
|
||||
"downloadTooltip": "Download this version",
|
||||
"downloadRemainingTooltip": "Download remaining files of this version",
|
||||
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
||||
"downloadPaidTooltip": "Download this paid version from Civitai",
|
||||
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
|
||||
@@ -1942,6 +1945,7 @@
|
||||
"downloadPartialSuccess": "Downloaded {completed} of {total} LoRAs",
|
||||
"downloadPartialWithAccess": "Downloaded {completed} of {total} LoRAs. {accessFailures} failed due to access restrictions. Check your API key in settings or early access status.",
|
||||
"pleaseSelectVersion": "Please select a version",
|
||||
"pleaseSelectFile": "Please select at least one file",
|
||||
"versionExists": "This version already exists in your library",
|
||||
"downloadCompleted": "Download completed successfully",
|
||||
"downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded",
|
||||
|
||||
+2331
-2327
File diff suppressed because it is too large
Load Diff
+2331
-2327
File diff suppressed because it is too large
Load Diff
+2331
-2327
File diff suppressed because it is too large
Load Diff
+2331
-2327
File diff suppressed because it is too large
Load Diff
+2331
-2327
File diff suppressed because it is too large
Load Diff
+2331
-2327
File diff suppressed because it is too large
Load Diff
+2331
-2327
File diff suppressed because it is too large
Load Diff
+2331
-2327
File diff suppressed because it is too large
Load Diff
@@ -2428,8 +2428,8 @@ class ModelLibraryHandler:
|
||||
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||
|
||||
found_type = None
|
||||
file_path = None
|
||||
found_cache = None
|
||||
entries: list = []
|
||||
|
||||
for model_type, scanner in (
|
||||
("lora", lora_scanner),
|
||||
@@ -2440,27 +2440,43 @@ class ModelLibraryHandler:
|
||||
if cache and model_version_id in cache.version_index:
|
||||
found_type = model_type
|
||||
found_cache = cache
|
||||
entry = cache.version_index[model_version_id]
|
||||
file_path = entry.get("file_path")
|
||||
# A version can have several local files (#1058); collect
|
||||
# them all so the delete below covers every file.
|
||||
files_getter = getattr(cache, "get_files_by_version_id", None)
|
||||
if files_getter is not None:
|
||||
entries = files_getter(model_version_id)
|
||||
else:
|
||||
entries = [cache.version_index[model_version_id]]
|
||||
break
|
||||
|
||||
if not file_path:
|
||||
file_paths = [
|
||||
entry.get("file_path")
|
||||
for entry in entries
|
||||
if isinstance(entry, dict) and entry.get("file_path")
|
||||
]
|
||||
|
||||
if not file_paths:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Model version not found in any scanner cache"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
target_dir = os.path.dirname(file_path)
|
||||
base_name = os.path.basename(file_path)
|
||||
file_name, extension = os.path.splitext(base_name)
|
||||
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
|
||||
for file_path in file_paths:
|
||||
target_dir = os.path.dirname(file_path)
|
||||
base_name = os.path.basename(file_path)
|
||||
file_name, extension = os.path.splitext(base_name)
|
||||
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
|
||||
|
||||
if found_cache:
|
||||
removed_paths = set(file_paths)
|
||||
found_cache.raw_data = [
|
||||
item
|
||||
for item in found_cache.raw_data
|
||||
if item.get("file_path") != file_path
|
||||
if item.get("file_path") not in removed_paths
|
||||
]
|
||||
rebuild = getattr(found_cache, "rebuild_version_index", None)
|
||||
if rebuild is not None:
|
||||
rebuild()
|
||||
await found_cache.resort()
|
||||
|
||||
scanner_map = {
|
||||
@@ -2483,6 +2499,7 @@ class ModelLibraryHandler:
|
||||
"success": True,
|
||||
"modelType": found_type,
|
||||
"modelVersionId": model_version_id,
|
||||
"deletedFiles": len(file_paths),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -2189,6 +2189,19 @@ class ModelCivitaiHandler:
|
||||
else:
|
||||
version.pop("localPath", None)
|
||||
|
||||
# Per-file downloaded state so multi-file versions can show
|
||||
# which individual files are already in the library (#1058)
|
||||
local_entries: List[Any] = []
|
||||
if version_id is not None and cache:
|
||||
files_getter = getattr(cache, "get_files_by_version_id", None)
|
||||
if files_getter is not None:
|
||||
local_entries = files_getter(version_id)
|
||||
elif cache_entry is not None:
|
||||
local_entries = [cache_entry]
|
||||
version["downloadedFiles"] = self._match_downloaded_files(
|
||||
version, local_entries
|
||||
)
|
||||
|
||||
model_file = (
|
||||
self._find_model_file(version.get("files", []))
|
||||
if isinstance(version.get("files"), Iterable)
|
||||
@@ -2203,6 +2216,64 @@ class ModelCivitaiHandler:
|
||||
)
|
||||
return web.Response(status=500, text=str(exc))
|
||||
|
||||
@staticmethod
|
||||
def _match_downloaded_files(
|
||||
version: Mapping[str, Any], local_entries: List[Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Map local library entries back to individual files of a version.
|
||||
|
||||
Matching follows rule D2 (#1058): SHA256 is authoritative when the
|
||||
local entry carries one; otherwise fall back to extension-less file
|
||||
name equality. Returns ``[{fileId, fileName, filePath}]``.
|
||||
"""
|
||||
files = version.get("files")
|
||||
if not isinstance(files, list) or not local_entries:
|
||||
return []
|
||||
|
||||
by_hash: Dict[str, Mapping[str, Any]] = {}
|
||||
by_name: Dict[str, Mapping[str, Any]] = {}
|
||||
for file_info in files:
|
||||
if not isinstance(file_info, Mapping):
|
||||
continue
|
||||
sha = str(
|
||||
(file_info.get("hashes") or {}).get("SHA256") or ""
|
||||
).strip().lower()
|
||||
if sha:
|
||||
by_hash.setdefault(sha, file_info)
|
||||
name = str(file_info.get("name") or "").strip()
|
||||
if name:
|
||||
by_name.setdefault(os.path.splitext(name)[0], file_info)
|
||||
|
||||
downloaded: List[Dict[str, Any]] = []
|
||||
seen_keys: set = set()
|
||||
for entry in local_entries:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
matched: Optional[Mapping[str, Any]] = None
|
||||
local_hash = str(entry.get("sha256") or "").strip().lower()
|
||||
if local_hash:
|
||||
matched = by_hash.get(local_hash)
|
||||
if matched is None:
|
||||
local_name = str(entry.get("file_name") or "").strip()
|
||||
if local_name:
|
||||
matched = by_name.get(local_name)
|
||||
if matched is None:
|
||||
continue
|
||||
|
||||
file_id = matched.get("id")
|
||||
dedupe_key = file_id if file_id is not None else matched.get("name")
|
||||
if dedupe_key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(dedupe_key)
|
||||
downloaded.append(
|
||||
{
|
||||
"fileId": file_id,
|
||||
"fileName": matched.get("name"),
|
||||
"filePath": entry.get("file_path"),
|
||||
}
|
||||
)
|
||||
return downloaded
|
||||
|
||||
async def get_civitai_model_by_version(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
model_version_id = request.match_info.get("modelVersionId")
|
||||
|
||||
@@ -976,6 +976,7 @@ class DownloadManager:
|
||||
version_info,
|
||||
record.get("model_version_id"),
|
||||
record.get("save_path") or record.get("file_path"),
|
||||
file_info=file_info,
|
||||
)
|
||||
await self._sync_downloaded_version(
|
||||
model_type,
|
||||
@@ -1860,6 +1861,7 @@ class DownloadManager:
|
||||
version_info,
|
||||
model_version_id,
|
||||
save_path,
|
||||
file_info=file_info,
|
||||
)
|
||||
await self._sync_downloaded_version(
|
||||
model_type,
|
||||
@@ -1902,6 +1904,7 @@ class DownloadManager:
|
||||
version_info: Dict[str, Any],
|
||||
fallback_version_id=None,
|
||||
file_path: str | None = None,
|
||||
file_info: Dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||
@@ -1927,6 +1930,15 @@ class DownloadManager:
|
||||
if version_id is None:
|
||||
version_id = fallback_version_id
|
||||
|
||||
# Per-file identity for multi-file versions (#1058)
|
||||
file_id = None
|
||||
file_name = None
|
||||
if isinstance(file_info, dict):
|
||||
file_id = file_info.get("id")
|
||||
raw_file_name = file_info.get("name")
|
||||
if isinstance(raw_file_name, str) and raw_file_name.strip():
|
||||
file_name = raw_file_name.strip()
|
||||
|
||||
try:
|
||||
await history_service.mark_downloaded(
|
||||
model_type,
|
||||
@@ -1934,6 +1946,8 @@ class DownloadManager:
|
||||
model_id=int(cast(Any, resolved_model_id)) if resolved_model_id is not None else None,
|
||||
source="download",
|
||||
file_path=file_path,
|
||||
file_id=file_id,
|
||||
file_name=file_name,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
logger.debug(
|
||||
|
||||
@@ -62,6 +62,14 @@ class DownloadedVersionHistoryService:
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_downloaded_model_versions_model
|
||||
ON downloaded_model_versions(model_type, model_id);
|
||||
CREATE TABLE IF NOT EXISTS downloaded_version_files (
|
||||
model_type TEXT NOT NULL,
|
||||
version_id INTEGER NOT NULL,
|
||||
file_id INTEGER NOT NULL,
|
||||
file_name TEXT,
|
||||
downloaded_at REAL NOT NULL,
|
||||
PRIMARY KEY (model_type, version_id, file_id)
|
||||
);
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str | None = None, *, settings_manager=None) -> None:
|
||||
@@ -131,10 +139,13 @@ class DownloadedVersionHistoryService:
|
||||
source: str = "manual",
|
||||
file_path: str | None = None,
|
||||
library_name: str | None = None,
|
||||
file_id: int | None = None,
|
||||
file_name: str | None = None,
|
||||
) -> None:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
normalized_model_id = _normalize_int(model_id)
|
||||
normalized_file_id = _normalize_int(file_id)
|
||||
if normalized_type is None or normalized_version_id is None:
|
||||
return
|
||||
|
||||
@@ -168,6 +179,25 @@ class DownloadedVersionHistoryService:
|
||||
active_library_name,
|
||||
),
|
||||
)
|
||||
if normalized_file_id is not None:
|
||||
# Per-file history for multi-file versions (#1058)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO downloaded_version_files (
|
||||
model_type, version_id, file_id, file_name, downloaded_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(model_type, version_id, file_id) DO UPDATE SET
|
||||
file_name = COALESCE(excluded.file_name, downloaded_version_files.file_name),
|
||||
downloaded_at = excluded.downloaded_at
|
||||
""",
|
||||
(
|
||||
normalized_type,
|
||||
normalized_version_id,
|
||||
normalized_file_id,
|
||||
file_name,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def mark_downloaded_bulk(
|
||||
@@ -255,8 +285,63 @@ class DownloadedVersionHistoryService:
|
||||
self._get_active_library_name(),
|
||||
),
|
||||
)
|
||||
# Whole-version deletion also clears the per-file records (#1058)
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM downloaded_version_files
|
||||
WHERE model_type = ? AND version_id = ?
|
||||
""",
|
||||
(normalized_type, normalized_version_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def mark_file_deleted(
|
||||
self, model_type: str, version_id: int, file_id: int
|
||||
) -> None:
|
||||
"""Drop a single file record of a version, keeping siblings (#1058)."""
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
normalized_file_id = _normalize_int(file_id)
|
||||
if (
|
||||
normalized_type is None
|
||||
or normalized_version_id is None
|
||||
or normalized_file_id is None
|
||||
):
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM downloaded_version_files
|
||||
WHERE model_type = ? AND version_id = ? AND file_id = ?
|
||||
""",
|
||||
(normalized_type, normalized_version_id, normalized_file_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def get_downloaded_file_ids(
|
||||
self, model_type: str, version_id: int
|
||||
) -> list[int]:
|
||||
"""Return the CivitAI file ids recorded as downloaded for a version."""
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
if normalized_type is None or normalized_version_id is None:
|
||||
return []
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT file_id
|
||||
FROM downloaded_version_files
|
||||
WHERE model_type = ? AND version_id = ?
|
||||
ORDER BY file_id ASC
|
||||
""",
|
||||
(normalized_type, normalized_version_id),
|
||||
).fetchall()
|
||||
return [int(row["file_id"]) for row in rows]
|
||||
|
||||
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
|
||||
@@ -35,6 +35,10 @@ class ModelCache:
|
||||
folders: List[str]
|
||||
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
||||
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||
# Multi-valued companion to version_index: every local file entry of a
|
||||
# CivitAI model version, so versions with several downloaded files stay
|
||||
# consistent (#1058).
|
||||
version_files_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||
name_display_mode: str = "model_name"
|
||||
_lock: Any = field(init=False, repr=False, default=None)
|
||||
# Cache for last sort: (sort_key, order, seed) -> sorted list
|
||||
@@ -116,6 +120,7 @@ class ModelCache:
|
||||
|
||||
self.version_index = {}
|
||||
self.model_id_index = {}
|
||||
self.version_files_index = {}
|
||||
for item in self.raw_data:
|
||||
self.add_to_version_index(item)
|
||||
|
||||
@@ -132,6 +137,17 @@ class ModelCache:
|
||||
|
||||
self.version_index[version_id] = item
|
||||
|
||||
# Register in the multi-valued index, deduplicated by file_path (#1058)
|
||||
files = self.version_files_index.setdefault(version_id, [])
|
||||
for entry in files:
|
||||
if entry is item or (
|
||||
isinstance(entry, dict)
|
||||
and entry.get('file_path') == item.get('file_path')
|
||||
):
|
||||
break
|
||||
else:
|
||||
files.append(item)
|
||||
|
||||
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
||||
if model_id is None:
|
||||
return
|
||||
@@ -159,12 +175,37 @@ class ModelCache:
|
||||
if version_id is None:
|
||||
return
|
||||
|
||||
# Drop only this file's entry from the multi-valued index (#1058)
|
||||
files = self.version_files_index.get(version_id)
|
||||
if files:
|
||||
remaining = [
|
||||
entry
|
||||
for entry in files
|
||||
if not (
|
||||
entry is item
|
||||
or (
|
||||
isinstance(entry, dict)
|
||||
and entry.get('file_path') == item.get('file_path')
|
||||
)
|
||||
)
|
||||
]
|
||||
if remaining:
|
||||
self.version_files_index[version_id] = remaining
|
||||
else:
|
||||
self.version_files_index.pop(version_id, None)
|
||||
|
||||
# A surviving sibling file keeps the version present in the indexes
|
||||
sibling = (self.version_files_index.get(version_id) or [None])[0]
|
||||
|
||||
existing = self.version_index.get(version_id)
|
||||
if existing is item or (
|
||||
isinstance(existing, dict)
|
||||
and existing.get('file_path') == item.get('file_path')
|
||||
):
|
||||
self.version_index.pop(version_id, None)
|
||||
if sibling is not None:
|
||||
self.version_index[version_id] = sibling
|
||||
else:
|
||||
self.version_index.pop(version_id, None)
|
||||
|
||||
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
||||
if model_id is None:
|
||||
@@ -174,6 +215,20 @@ class ModelCache:
|
||||
if not versions:
|
||||
return
|
||||
|
||||
if sibling is not None:
|
||||
# Update the descriptor to reflect the surviving sibling file
|
||||
descriptor = self._build_version_descriptor(
|
||||
sibling,
|
||||
sibling.get('civitai') if isinstance(sibling, dict) else {},
|
||||
version_id,
|
||||
)
|
||||
for index, existing_desc in enumerate(versions):
|
||||
if existing_desc.get('versionId') == version_id:
|
||||
if descriptor is not None:
|
||||
versions[index] = descriptor
|
||||
break
|
||||
return
|
||||
|
||||
filtered = [v for v in versions if v.get('versionId') != version_id]
|
||||
if filtered:
|
||||
self.model_id_index[model_id] = filtered
|
||||
@@ -206,6 +261,15 @@ class ModelCache:
|
||||
versions = self.model_id_index.get(normalized_id, [])
|
||||
return [dict(version) for version in versions]
|
||||
|
||||
def get_files_by_version_id(self, version_id: Any) -> List[Dict[str, Any]]:
|
||||
"""Return every local file entry for a CivitAI model version (#1058)."""
|
||||
|
||||
normalized_id = self._normalize_version_id(version_id)
|
||||
if normalized_id is None:
|
||||
return []
|
||||
|
||||
return list(self.version_files_index.get(normalized_id, []))
|
||||
|
||||
async def resort(self):
|
||||
"""Resort cached data according to last sort mode if set"""
|
||||
async with self._lock:
|
||||
|
||||
@@ -2446,6 +2446,39 @@ class ModelScanner:
|
||||
logger.error(f"Error checking model version existence: {e}")
|
||||
return False
|
||||
|
||||
async def get_files_for_version(self, model_version_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all local file entries for a specific model version (#1058).
|
||||
|
||||
A Civitai model version can have several weight files downloaded;
|
||||
unlike the single-valued version_index this returns every entry.
|
||||
|
||||
Args:
|
||||
model_version_id: Civitai model version ID
|
||||
|
||||
Returns:
|
||||
List[Dict]: Cache entries (may be empty)
|
||||
"""
|
||||
try:
|
||||
normalized_id = int(model_version_id)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
|
||||
try:
|
||||
cache = await self.get_cached_data()
|
||||
if not cache:
|
||||
return []
|
||||
|
||||
getter = getattr(cache, "get_files_by_version_id", None)
|
||||
if getter is not None:
|
||||
return getter(normalized_id)
|
||||
|
||||
# Fallback for cache implementations without the multi-file index
|
||||
entry = cache.version_index.get(normalized_id)
|
||||
return [entry] if entry is not None else []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting files for model version: {e}")
|
||||
return []
|
||||
|
||||
async def get_model_versions_by_id(self, model_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all versions of a model by its ID
|
||||
|
||||
|
||||
@@ -603,6 +603,51 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-option-radio input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--lora-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Files already in the library are greyed out and not clickable */
|
||||
.file-option.disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-option.disabled:hover {
|
||||
border-color: var(--border-color);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.file-option.disabled input[type="checkbox"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Options of the other routing group are temporarily disabled once a
|
||||
selection is made (mixed-type multi-select is not allowed) */
|
||||
.file-option.group-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-option.group-disabled:hover {
|
||||
border-color: var(--border-color);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.file-option.group-disabled input[type="checkbox"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-tag.in-library {
|
||||
background: oklch(var(--lora-accent) / 0.15);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.file-option-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -573,46 +573,53 @@ function renderRow(version, options) {
|
||||
);
|
||||
|
||||
const actions = [];
|
||||
if (!version.isInLibrary) {
|
||||
const canDownload = isDownloadAllowed(version);
|
||||
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
||||
let downloadTitle;
|
||||
if (!canDownload) {
|
||||
downloadTitle = translate(
|
||||
'modals.model.versions.actions.downloadNotAllowedTooltip',
|
||||
{},
|
||||
'This version is only available for on-site generation on Civitai'
|
||||
);
|
||||
} else if (isPaidPermanent(version)) {
|
||||
downloadTitle = translate(
|
||||
'modals.model.versions.actions.downloadPaidTooltip',
|
||||
{},
|
||||
'Download this paid version from Civitai'
|
||||
);
|
||||
} else if (isEarlyAccess) {
|
||||
downloadTitle = translate(
|
||||
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
||||
{},
|
||||
'Download this early access version from Civitai'
|
||||
);
|
||||
} else {
|
||||
downloadTitle = translate(
|
||||
'modals.model.versions.actions.downloadTooltip',
|
||||
{},
|
||||
'Download this version'
|
||||
);
|
||||
const canDownload = isDownloadAllowed(version);
|
||||
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
||||
let downloadTitle;
|
||||
if (!canDownload) {
|
||||
downloadTitle = translate(
|
||||
'modals.model.versions.actions.downloadNotAllowedTooltip',
|
||||
{},
|
||||
'This version is only available for on-site generation on Civitai'
|
||||
);
|
||||
} else if (version.isInLibrary) {
|
||||
// In-library versions may still have undownloaded weight files; the
|
||||
// download modal's file dialog decides what remains (#1058).
|
||||
downloadTitle = translate(
|
||||
'modals.model.versions.actions.downloadRemainingTooltip',
|
||||
{},
|
||||
'Download remaining files of this version'
|
||||
);
|
||||
} else if (isPaidPermanent(version)) {
|
||||
downloadTitle = translate(
|
||||
'modals.model.versions.actions.downloadPaidTooltip',
|
||||
{},
|
||||
'Download this paid version from Civitai'
|
||||
);
|
||||
} else if (isEarlyAccess) {
|
||||
downloadTitle = translate(
|
||||
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
||||
{},
|
||||
'Download this early access version from Civitai'
|
||||
);
|
||||
} else {
|
||||
downloadTitle = translate(
|
||||
'modals.model.versions.actions.downloadTooltip',
|
||||
{},
|
||||
'Download this version'
|
||||
);
|
||||
}
|
||||
actions.push(buildActionButton(
|
||||
downloadLabel,
|
||||
canDownload ? 'version-action-primary' : 'version-action-disabled',
|
||||
canDownload ? 'download' : '',
|
||||
{
|
||||
title: downloadTitle,
|
||||
iconMarkup: downloadIcon,
|
||||
disabled: !canDownload,
|
||||
}
|
||||
actions.push(buildActionButton(
|
||||
downloadLabel,
|
||||
canDownload ? 'version-action-primary' : 'version-action-disabled',
|
||||
canDownload ? 'download' : '',
|
||||
{
|
||||
title: downloadTitle,
|
||||
iconMarkup: downloadIcon,
|
||||
disabled: !canDownload,
|
||||
}
|
||||
));
|
||||
} else if (version.filePath) {
|
||||
));
|
||||
if (version.isInLibrary && version.filePath) {
|
||||
actions.push(buildActionButton(
|
||||
deleteLabel,
|
||||
'version-action-danger',
|
||||
@@ -1422,6 +1429,15 @@ export function initVersionsTab({
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
// In-library versions may still have undownloaded weight files
|
||||
// (#1058). The tab payload has no per-file state, so open the
|
||||
// download modal's file dialog, which refetches the full version
|
||||
// payload and shows what remains.
|
||||
if (version.isInLibrary) {
|
||||
await downloadManager.openFileSelectionForVersion(modelType, modelId, versionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const pathInfo = await resolveDownloadPathFromCurrentVersion();
|
||||
const resolveTemplatePath = shouldResolveTemplatePath(version, pathInfo);
|
||||
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
|
||||
|
||||
@@ -25,6 +25,12 @@ export class DownloadManager {
|
||||
this.apiClient = null;
|
||||
this.useDefaultPath = false;
|
||||
|
||||
// Multi-file selection state: selectedFile stays the first selected
|
||||
// file for backward compatibility with single-file flows (#1058).
|
||||
this.selectedFile = null;
|
||||
this.selectedFiles = [];
|
||||
this._lastDownloadError = null;
|
||||
|
||||
// Batch mode state
|
||||
this.batchModels = [];
|
||||
this.isBatchMode = false;
|
||||
@@ -160,6 +166,8 @@ export class DownloadManager {
|
||||
this.modelVersionId = null;
|
||||
this.source = null;
|
||||
this.selectedFile = null;
|
||||
this.selectedFiles = [];
|
||||
this._lastDownloadError = null;
|
||||
this._isDiffusionModel = false;
|
||||
|
||||
this.selectedFolder = '';
|
||||
@@ -546,6 +554,64 @@ export class DownloadManager {
|
||||
await this.fetchVersionsForCurrentModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the download modal directly on the file-selection step for a
|
||||
* specific model version (#1058). Used by entry points (e.g.
|
||||
* ModelVersionsTab) whose version payloads lack per-file downloaded
|
||||
* state, so the full versions payload is fetched here first.
|
||||
*/
|
||||
async openFileSelectionForVersion(modelType, modelId, versionId, { source = null } = {}) {
|
||||
try {
|
||||
this.apiClient = getModelApiClient(modelType);
|
||||
} catch (error) {
|
||||
this.apiClient = getModelApiClient();
|
||||
}
|
||||
|
||||
this.showDownloadModal();
|
||||
|
||||
this.modelId = modelId ? modelId.toString() : null;
|
||||
this.modelVersionId = versionId ? versionId.toString() : null;
|
||||
this.source = source;
|
||||
|
||||
if (!this.modelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
|
||||
await this.retrieveVersionsForModel(this.modelId, this.source);
|
||||
} catch (error) {
|
||||
showToast('toast.downloads.loadError', { message: error.message }, 'error');
|
||||
return;
|
||||
} finally {
|
||||
this.loadingManager.hide();
|
||||
}
|
||||
|
||||
const version = this.versions.find(v => v.id.toString() === this.modelVersionId);
|
||||
if (!version) {
|
||||
console.warn('[download] openFileSelectionForVersion: version %s not found for model %s',
|
||||
this.modelVersionId, this.modelId);
|
||||
this.showVersionStep();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasRemainingFiles = this._getWeightFiles(version).length > 1
|
||||
&& this._getRemainingFiles(version).length > 0;
|
||||
|
||||
if (hasRemainingFiles) {
|
||||
this.showFileSelectionStep(version.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing left to download for this version (single file or all
|
||||
// files already in the library) — fall back to the version step.
|
||||
if (version.existsLocally) {
|
||||
showToast('toast.loras.versionExists', {}, 'info');
|
||||
}
|
||||
this.currentVersion = version;
|
||||
this.showVersionStep();
|
||||
}
|
||||
|
||||
showVersionStep() {
|
||||
document.getElementById('urlStep').style.display = 'none';
|
||||
document.getElementById('versionStep').style.display = 'block';
|
||||
@@ -670,9 +736,14 @@ export class DownloadManager {
|
||||
const nextButton = document.getElementById('nextFromVersion');
|
||||
if (!nextButton) return;
|
||||
|
||||
const existsLocally = this.currentVersion?.existsLocally;
|
||||
const version = this.currentVersion;
|
||||
const existsLocally = version?.existsLocally;
|
||||
// A partially downloaded multi-file version still has downloadable
|
||||
// files, so Next routes into the file dialog instead of blocking (#1058).
|
||||
const hasRemainingFiles = this._getWeightFiles(version).length > 1
|
||||
&& this._getRemainingFiles(version).length > 0;
|
||||
|
||||
if (existsLocally) {
|
||||
if (existsLocally && !hasRemainingFiles) {
|
||||
nextButton.disabled = true;
|
||||
nextButton.classList.add('disabled');
|
||||
nextButton.textContent = translate('modals.download.alreadyInLibrary');
|
||||
@@ -683,12 +754,36 @@ export class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
_getWeightFiles(version) {
|
||||
return (version?.files || []).filter(f => isModelWeightFile(f.type));
|
||||
}
|
||||
|
||||
_getRemainingFiles(version) {
|
||||
const downloadedIds = new Set(
|
||||
(version?.downloadedFiles || []).map(f => String(f.fileId))
|
||||
);
|
||||
return this._getWeightFiles(version).filter(f => !downloadedIds.has(String(f.id)));
|
||||
}
|
||||
|
||||
// Files of type UNet / Diffusion Model are routed to the diffusion_model
|
||||
// root while regular files go to the model-type root, so a single
|
||||
// multi-file selection session must stay within one routing group.
|
||||
_getFileRoutingGroup(file) {
|
||||
return (file.type === 'UNet' || file.type === 'Diffusion Model') ? 'diffusion' : 'model';
|
||||
}
|
||||
|
||||
showFileSelectionStep(versionId) {
|
||||
const version = this.versions.find(v => v.id.toString() === versionId.toString());
|
||||
if (!version) return;
|
||||
|
||||
this.currentVersion = version;
|
||||
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type));
|
||||
// Start each file-selection session with a clean selection
|
||||
this.selectedFiles = [];
|
||||
this.selectedFile = null;
|
||||
const modelFiles = this._getWeightFiles(version);
|
||||
const downloadedIds = new Set(
|
||||
(version.downloadedFiles || []).map(f => String(f.fileId))
|
||||
);
|
||||
|
||||
document.getElementById('versionStep').style.display = 'none';
|
||||
document.getElementById('fileSelectionStep').style.display = 'block';
|
||||
@@ -702,9 +797,12 @@ export class DownloadManager {
|
||||
container.innerHTML = modelFiles.map(file => {
|
||||
const meta = file.metadata || {};
|
||||
const sizeGB = file.sizeKB ? (file.sizeKB / (1024 * 1024)).toFixed(2) : '--';
|
||||
const isSelected = this.selectedFile?.id === file.id;
|
||||
const isDownloaded = downloadedIds.has(String(file.id));
|
||||
|
||||
const tags = [];
|
||||
if (isDownloaded) {
|
||||
tags.push(`<span class="file-tag in-library">${translate('modals.download.fileSelection.inLibrary', {}, 'In Library')}</span>`);
|
||||
}
|
||||
if (meta.size) tags.push(`<span class="file-tag size">${meta.size}</span>`);
|
||||
if (meta.format) tags.push(`<span class="file-tag format">${meta.format}</span>`);
|
||||
if (meta.fp) tags.push(`<span class="file-tag fp">${meta.fp}</span>`);
|
||||
@@ -712,9 +810,9 @@ export class DownloadManager {
|
||||
const fileName = file.name || '';
|
||||
|
||||
return `
|
||||
<div class="file-option ${isSelected ? 'selected' : ''}" data-file-id="${file.id}">
|
||||
<div class="file-option ${isDownloaded ? 'disabled' : ''}" data-file-id="${file.id}">
|
||||
<div class="file-option-radio">
|
||||
<input type="radio" name="fileSelection" value="${file.id}" ${isSelected ? 'checked' : ''}>
|
||||
<input type="checkbox" name="fileSelection" value="${file.id}" ${isDownloaded ? 'disabled' : ''}>
|
||||
</div>
|
||||
<div class="file-option-info">
|
||||
<div class="file-option-tags">
|
||||
@@ -728,33 +826,80 @@ export class DownloadManager {
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.file-option').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
container.querySelectorAll('.file-option').forEach(o => o.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
const radio = el.querySelector('input[type="radio"]');
|
||||
if (radio) radio.checked = true;
|
||||
el.addEventListener('click', (event) => {
|
||||
// Already-downloaded files stay disabled regardless
|
||||
if (el.classList.contains('disabled')) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const checkbox = el.querySelector('input[type="checkbox"]');
|
||||
if (!checkbox || checkbox.disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Clicking the checkbox directly toggles natively; clicking
|
||||
// anywhere else on the option toggles it programmatically.
|
||||
if (event.target !== checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
}
|
||||
this._syncFileSelectionState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
confirmFileSelection() {
|
||||
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
|
||||
if (!selectedRadio) {
|
||||
console.warn('[download] confirmFileSelection: no radio button checked');
|
||||
return;
|
||||
}
|
||||
// Sync this.selectedFiles with the DOM checkboxes and enforce the
|
||||
// mixed-type routing guard by disabling the other routing group.
|
||||
_syncFileSelectionState() {
|
||||
const container = document.getElementById('fileSelectionList');
|
||||
if (!container || !this.currentVersion) return;
|
||||
|
||||
const checkedValues = new Set(
|
||||
Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
|
||||
.map(cb => cb.value)
|
||||
);
|
||||
const modelFiles = this._getWeightFiles(this.currentVersion);
|
||||
this.selectedFiles = modelFiles.filter(f => checkedValues.has(f.id.toString()));
|
||||
this.selectedFile = this.selectedFiles[0] || null;
|
||||
|
||||
const activeGroup = this.selectedFiles.length > 0
|
||||
? this._getFileRoutingGroup(this.selectedFiles[0])
|
||||
: null;
|
||||
|
||||
container.querySelectorAll('.file-option').forEach(el => {
|
||||
const checkbox = el.querySelector('input[type="checkbox"]');
|
||||
if (!checkbox || el.classList.contains('disabled')) return;
|
||||
|
||||
const file = modelFiles.find(f => f.id.toString() === el.dataset.fileId);
|
||||
const groupBlocked = activeGroup !== null
|
||||
&& file
|
||||
&& this._getFileRoutingGroup(file) !== activeGroup
|
||||
&& !checkbox.checked;
|
||||
|
||||
el.classList.toggle('selected', checkbox.checked);
|
||||
el.classList.toggle('group-disabled', groupBlocked);
|
||||
checkbox.disabled = groupBlocked;
|
||||
});
|
||||
}
|
||||
|
||||
confirmFileSelection() {
|
||||
const version = this.currentVersion;
|
||||
if (!version) {
|
||||
console.warn('[download] confirmFileSelection: no currentVersion set');
|
||||
return;
|
||||
}
|
||||
|
||||
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type));
|
||||
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
|
||||
// Sync from the DOM first so programmatically checked boxes count too
|
||||
this._syncFileSelectionState();
|
||||
|
||||
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o',
|
||||
this.selectedFile?.id, this.selectedFile?.name, this.selectedFile?.type, this.selectedFile?.metadata);
|
||||
if (this.selectedFiles.length === 0) {
|
||||
console.warn('[download] confirmFileSelection: no file selected');
|
||||
showToast('toast.loras.pleaseSelectFile', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[download] confirmFileSelection: %d file(s) selected — %o',
|
||||
this.selectedFiles.length,
|
||||
this.selectedFiles.map(f => ({ id: f.id, name: f.name, type: f.type })));
|
||||
|
||||
document.getElementById('fileSelectionStep').style.display = 'none';
|
||||
document.getElementById('downloadLocationStep').style.display = 'block';
|
||||
@@ -785,6 +930,13 @@ export class DownloadManager {
|
||||
return;
|
||||
}
|
||||
if (this.currentVersion.existsLocally) {
|
||||
// Multi-file versions with remaining undownloaded files route
|
||||
// into the file dialog instead of being blocked outright (#1058).
|
||||
if (this._getWeightFiles(this.currentVersion).length > 1
|
||||
&& this._getRemainingFiles(this.currentVersion).length > 0) {
|
||||
this.showFileSelectionStep(this.currentVersion.id);
|
||||
return;
|
||||
}
|
||||
showToast('toast.loras.versionExists', {}, 'info');
|
||||
return;
|
||||
}
|
||||
@@ -919,6 +1071,9 @@ export class DownloadManager {
|
||||
source = null,
|
||||
fileParams = null,
|
||||
closeModal = false,
|
||||
deferReload = false,
|
||||
suppressSuccessToast = false,
|
||||
suppressFailureSummary = false,
|
||||
}) {
|
||||
const config = this.apiClient?.apiConfig?.config;
|
||||
|
||||
@@ -927,7 +1082,8 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
const displayName = versionName || `#${versionId}`;
|
||||
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false };
|
||||
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false, deferReload, suppressSuccessToast, suppressFailureSummary };
|
||||
this._lastDownloadError = null;
|
||||
let ws = null;
|
||||
let updateProgress = () => { };
|
||||
let cancelled = false;
|
||||
@@ -1010,7 +1166,9 @@ export class DownloadManager {
|
||||
if (response?.skipped) {
|
||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||
updateProgress(100, 0, displayName);
|
||||
showToast('toast.loras.downloadSkippedByBaseModel', { baseModel: response.base_model || 'Unknown' }, 'warning');
|
||||
if (!suppressSuccessToast) {
|
||||
showToast('toast.loras.downloadSkippedByBaseModel', { baseModel: response.base_model || 'Unknown' }, 'warning');
|
||||
}
|
||||
if (closeModal) {
|
||||
modalManager.closeModal('downloadModal');
|
||||
}
|
||||
@@ -1020,6 +1178,12 @@ export class DownloadManager {
|
||||
if (!response?.success) {
|
||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||
const errorMessage = response?.error || 'Unknown error';
|
||||
// When the caller aggregates failures itself (multi-file
|
||||
// loop), just record the error and return (#1058).
|
||||
if (suppressFailureSummary) {
|
||||
this._lastDownloadError = errorMessage;
|
||||
return false;
|
||||
}
|
||||
// A file-level "already in library" rejection is an expected
|
||||
// outcome when browsing files of a partially downloaded
|
||||
// version — surface it as a lightweight toast instead of the
|
||||
@@ -1047,7 +1211,9 @@ export class DownloadManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||
if (!suppressSuccessToast) {
|
||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||
}
|
||||
|
||||
if (closeModal) {
|
||||
modalManager.closeModal('downloadModal');
|
||||
@@ -1058,29 +1224,35 @@ export class DownloadManager {
|
||||
ws = null;
|
||||
}
|
||||
|
||||
const pageState = this.apiClient.getPageState();
|
||||
if (!deferReload) {
|
||||
const pageState = this.apiClient.getPageState();
|
||||
|
||||
if (!useDefaultPaths && targetFolder) {
|
||||
pageState.activeFolder = targetFolder;
|
||||
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
|
||||
if (!useDefaultPaths && targetFolder) {
|
||||
pageState.activeFolder = targetFolder;
|
||||
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
|
||||
|
||||
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
|
||||
const isActive = tag.dataset.folder === targetFolder;
|
||||
tag.classList.toggle('active', isActive);
|
||||
if (isActive && !tag.parentNode.classList.contains('collapsed')) {
|
||||
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
|
||||
const isActive = tag.dataset.folder === targetFolder;
|
||||
tag.classList.toggle('active', isActive);
|
||||
if (isActive && !tag.parentNode.classList.contains('collapsed')) {
|
||||
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await resetAndReload(true);
|
||||
}
|
||||
|
||||
await resetAndReload(true);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
console.log('Download cancelled by user:', downloadId);
|
||||
} else {
|
||||
console.error('Failed to download model version:', error);
|
||||
if (suppressFailureSummary) {
|
||||
this._lastDownloadError = error?.message || 'Unknown error';
|
||||
return false;
|
||||
}
|
||||
showDownloadBatchSummary({
|
||||
total: 1,
|
||||
completed: 0,
|
||||
@@ -1110,6 +1282,89 @@ export class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download multiple selected files of the same version sequentially,
|
||||
* reusing the location-step choices for every file. Per-file toasts,
|
||||
* reloads and failure modals are suppressed; a single aggregated result
|
||||
* is shown at the end (design decision D5, #1058).
|
||||
*/
|
||||
async _downloadSelectedFilesSequentially({ modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot = false, files = null }) {
|
||||
const filesToDownload = files || this.selectedFiles;
|
||||
const totalFiles = filesToDownload.length;
|
||||
const failedItems = [];
|
||||
let completedDownloads = 0;
|
||||
|
||||
for (const file of filesToDownload) {
|
||||
const fileParams = {
|
||||
id: file.id,
|
||||
name: file.name || null,
|
||||
type: file.type || 'Model',
|
||||
format: file.metadata?.format || null,
|
||||
size: file.metadata?.size || null,
|
||||
fp: file.metadata?.fp || null,
|
||||
};
|
||||
|
||||
console.log('[download] multi-file loop: downloading file id=%s, name="%s" (%d/%d)',
|
||||
fileParams.id, fileParams.name, completedDownloads + failedItems.length + 1, totalFiles);
|
||||
|
||||
const success = await this.executeDownloadWithProgress({
|
||||
modelId: this.modelId,
|
||||
versionId: this.currentVersion.id,
|
||||
versionName: file.name || `${this.currentVersion.name} #${file.id}`,
|
||||
modelRoot,
|
||||
targetFolder,
|
||||
useDefaultPaths,
|
||||
useSaveDirAsRoot,
|
||||
source: this.source,
|
||||
fileParams,
|
||||
closeModal: false,
|
||||
deferReload: true,
|
||||
suppressSuccessToast: true,
|
||||
suppressFailureSummary: true,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
completedDownloads++;
|
||||
} else {
|
||||
failedItems.push({
|
||||
item: {
|
||||
modelId: this.modelId,
|
||||
versionId: this.currentVersion.id,
|
||||
source: this.source,
|
||||
file,
|
||||
url: this._buildSingleItemUrl({
|
||||
modelId: this.modelId,
|
||||
versionId: this.currentVersion.id,
|
||||
source: this.source,
|
||||
}),
|
||||
},
|
||||
error: this._lastDownloadError || 'Unknown error',
|
||||
name: file.name || `#${file.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (failedItems.length === 0) {
|
||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||
} else {
|
||||
showDownloadBatchSummary({
|
||||
total: totalFiles,
|
||||
completed: completedDownloads,
|
||||
failedItems,
|
||||
onRetry: () => this._downloadSelectedFilesSequentially({
|
||||
modelRoot,
|
||||
targetFolder,
|
||||
useDefaultPaths,
|
||||
useSaveDirAsRoot,
|
||||
files: failedItems.map(f => f.item.file),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
await resetAndReload(true);
|
||||
return failedItems.length === 0;
|
||||
}
|
||||
|
||||
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths, files = null }) {
|
||||
modalManager.closeModal('downloadModal');
|
||||
this.loadingManager.restoreProgressBar();
|
||||
@@ -1320,6 +1575,14 @@ export class DownloadManager {
|
||||
? (ver.modelSizeKB / 1024).toFixed(1)
|
||||
: (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?');
|
||||
const existsLocally = ver?.existsLocally;
|
||||
// Multi-file versions that are only partially downloaded get a
|
||||
// distinct hint instead of the plain in-library badge (#1058).
|
||||
const isPartiallyDownloaded = existsLocally
|
||||
&& this._getWeightFiles(ver).length > 1
|
||||
&& this._getRemainingFiles(ver).length > 0;
|
||||
const localBadgeLabel = isPartiallyDownloaded
|
||||
? translate('modals.download.partiallyDownloaded', {}, 'Partially downloaded')
|
||||
: translate('modals.download.inLibrary');
|
||||
return `
|
||||
<div class="batch-preview-item ${existsLocally ? 'batch-preview-local' : ''}" data-index="${index}">
|
||||
<div class="batch-preview-thumbnail">
|
||||
@@ -1330,7 +1593,7 @@ export class DownloadManager {
|
||||
<div class="batch-preview-meta">
|
||||
${ver?.baseModel ? `<span>${ver.baseModel}</span>` : ''}
|
||||
<span>${fileSize} MB</span>
|
||||
${existsLocally ? `<span class="batch-preview-local-badge"><i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}</span>` : ''}
|
||||
${existsLocally ? `<span class="batch-preview-local-badge"><i class="fas fa-check"></i> ${localBadgeLabel}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${item.versions.length > 1 ? `
|
||||
@@ -1621,6 +1884,17 @@ export class DownloadManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Multi-file selection: download all selected files sequentially,
|
||||
// reusing the chosen location for every file (#1058).
|
||||
if (this.selectedFiles.length > 1) {
|
||||
modalManager.closeModal('downloadModal');
|
||||
return this._downloadSelectedFilesSequentially({
|
||||
modelRoot,
|
||||
targetFolder,
|
||||
useDefaultPaths,
|
||||
});
|
||||
}
|
||||
|
||||
const fileParams = this.selectedFile ? {
|
||||
id: this.selectedFile.id,
|
||||
name: this.selectedFile.name || null,
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
DOWNLOAD_MANAGER_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
STATE_MODULE,
|
||||
LOADING_MANAGER_MODULE,
|
||||
API_FACTORY_MODULE,
|
||||
STORAGE_HELPERS_MODULE,
|
||||
FOLDER_TREE_MANAGER_MODULE,
|
||||
I18N_HELPERS_MODULE,
|
||||
SUMMARY_MODULE,
|
||||
mockApiClient,
|
||||
mockLoadingManager,
|
||||
showToastMock,
|
||||
showDownloadBatchSummaryMock,
|
||||
resetAndReloadMock,
|
||||
} = vi.hoisted(() => {
|
||||
// Shared API client returned by the mocked getModelApiClient factory.
|
||||
const mockApiClient = {
|
||||
modelType: 'loras',
|
||||
apiConfig: {
|
||||
config: {
|
||||
displayName: 'LoRA',
|
||||
singularName: 'lora',
|
||||
},
|
||||
},
|
||||
fetchCivitaiVersions: vi.fn(),
|
||||
fetchModelRoots: vi.fn(async () => ({ roots: ['/models/loras'] })),
|
||||
fetchUnifiedFolderTree: vi.fn(async () => ({ success: false })),
|
||||
downloadModel: vi.fn(),
|
||||
cancelDownload: vi.fn(),
|
||||
getPageState: vi.fn(() => ({})),
|
||||
};
|
||||
|
||||
const mockLoadingManager = {
|
||||
showSimpleLoading: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
restoreProgressBar: vi.fn(),
|
||||
showDownloadProgress: vi.fn(() => vi.fn()),
|
||||
showCancelButton: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
|
||||
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
|
||||
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
|
||||
mockApiClient,
|
||||
mockLoadingManager,
|
||||
showToastMock: vi.fn(),
|
||||
showDownloadBatchSummaryMock: vi.fn(),
|
||||
resetAndReloadMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: {
|
||||
showModal: vi.fn(),
|
||||
closeModal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: showToastMock,
|
||||
setupAutoNewlineOnPaste: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(STATE_MODULE, () => ({
|
||||
state: {
|
||||
global: {
|
||||
settings: {},
|
||||
},
|
||||
loadingManager: mockLoadingManager,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(LOADING_MANAGER_MODULE, () => ({
|
||||
LoadingManager: vi.fn(() => mockLoadingManager),
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY_MODULE, () => ({
|
||||
getModelApiClient: vi.fn(() => mockApiClient),
|
||||
resetAndReload: resetAndReloadMock,
|
||||
}));
|
||||
|
||||
vi.mock(STORAGE_HELPERS_MODULE, () => ({
|
||||
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
|
||||
setStorageItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
|
||||
FolderTreeManager: vi.fn(() => ({
|
||||
clearSelection: vi.fn(),
|
||||
init: vi.fn(),
|
||||
getSelectedPath: vi.fn(() => ''),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||
translate: vi.fn((_, __, fallback) => fallback ?? ''),
|
||||
}));
|
||||
|
||||
vi.mock(SUMMARY_MODULE, () => ({
|
||||
showDownloadBatchSummary: showDownloadBatchSummaryMock,
|
||||
}));
|
||||
|
||||
/** DOM covering the file-selection, version and location steps. */
|
||||
function setupDownloadDom() {
|
||||
document.body.innerHTML = `
|
||||
<div id="downloadModal">
|
||||
<div class="download-step" id="urlStep"></div>
|
||||
<div class="download-step" id="versionStep"></div>
|
||||
<div class="download-step" id="fileSelectionStep"></div>
|
||||
<div class="download-step" id="downloadLocationStep"></div>
|
||||
<div id="fileSelectionList"></div>
|
||||
<div id="fileSelectionVersionName"></div>
|
||||
<button id="nextFromVersion"></button>
|
||||
<div id="downloadModalTitle"></div>
|
||||
<select id="modelRoot"></select>
|
||||
<input id="folderPath" />
|
||||
<div id="targetPathDisplay"></div>
|
||||
<input id="useDefaultPath" type="checkbox" />
|
||||
<div id="manualPathSelection"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function makeMultiFileVersion(overrides = {}) {
|
||||
return {
|
||||
id: 201,
|
||||
name: 'Multi-file version',
|
||||
baseModel: 'SDXL',
|
||||
images: [],
|
||||
files: [
|
||||
{ id: 1001, type: 'Model', sizeKB: 2048, name: 'file-a.safetensors' },
|
||||
{ id: 1002, type: 'Model', sizeKB: 2048, name: 'file-b.safetensors' },
|
||||
{ id: 1003, type: 'Model', sizeKB: 2048, name: 'file-c.safetensors' },
|
||||
],
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
existsLocally: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function getFileOption(fileId) {
|
||||
return document.querySelector(`.file-option[data-file-id="${fileId}"]`);
|
||||
}
|
||||
|
||||
describe('DownloadManager multi-select file dialog (#1058)', () => {
|
||||
let DownloadManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
setupDownloadDom();
|
||||
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('renders downloaded files disabled with an In Library tag', () => {
|
||||
const manager = new DownloadManager();
|
||||
manager.versions = [makeMultiFileVersion({
|
||||
downloadedFiles: [
|
||||
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
|
||||
],
|
||||
})];
|
||||
|
||||
manager.showFileSelectionStep('201');
|
||||
|
||||
const downloadedOption = getFileOption('1001');
|
||||
expect(downloadedOption.classList.contains('disabled')).toBe(true);
|
||||
expect(downloadedOption.querySelector('input[type="checkbox"]').disabled).toBe(true);
|
||||
expect(downloadedOption.querySelector('.file-tag.in-library').textContent).toBe('In Library');
|
||||
|
||||
// Remaining files stay selectable
|
||||
const otherOption = getFileOption('1002');
|
||||
expect(otherOption.classList.contains('disabled')).toBe(false);
|
||||
expect(otherOption.querySelector('input[type="checkbox"]').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores clicks on already-downloaded options', () => {
|
||||
const manager = new DownloadManager();
|
||||
manager.versions = [makeMultiFileVersion({
|
||||
downloadedFiles: [
|
||||
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
|
||||
],
|
||||
})];
|
||||
|
||||
manager.showFileSelectionStep('201');
|
||||
|
||||
getFileOption('1001').click();
|
||||
|
||||
expect(getFileOption('1001').querySelector('input[type="checkbox"]').checked).toBe(false);
|
||||
expect(manager.selectedFiles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('confirmFileSelection collects multiple checked files into selectedFiles', () => {
|
||||
const manager = new DownloadManager();
|
||||
manager.apiClient = mockApiClient;
|
||||
manager.versions = [makeMultiFileVersion({ downloadedFiles: [] })];
|
||||
|
||||
manager.showFileSelectionStep('201');
|
||||
|
||||
getFileOption('1001').click();
|
||||
getFileOption('1003').click();
|
||||
|
||||
manager.confirmFileSelection();
|
||||
|
||||
expect(manager.selectedFiles.map(f => f.id)).toEqual([1001, 1003]);
|
||||
// selectedFile stays the first selected file for single-file flows
|
||||
expect(manager.selectedFile?.id).toBe(1001);
|
||||
expect(document.getElementById('fileSelectionStep').style.display).toBe('none');
|
||||
expect(document.getElementById('downloadLocationStep').style.display).toBe('block');
|
||||
});
|
||||
|
||||
it('confirmFileSelection warns when nothing is selected', () => {
|
||||
const manager = new DownloadManager();
|
||||
manager.versions = [makeMultiFileVersion({ downloadedFiles: [] })];
|
||||
|
||||
manager.showFileSelectionStep('201');
|
||||
manager.confirmFileSelection();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.loras.pleaseSelectFile', {}, 'error');
|
||||
expect(manager.selectedFiles).toHaveLength(0);
|
||||
expect(document.getElementById('downloadLocationStep').style.display).not.toBe('block');
|
||||
});
|
||||
|
||||
it('disables the other routing group once a file is checked and re-enables when unchecked', () => {
|
||||
const manager = new DownloadManager();
|
||||
manager.versions = [makeMultiFileVersion({
|
||||
downloadedFiles: [],
|
||||
files: [
|
||||
{ id: 1001, type: 'UNet', sizeKB: 2048, name: 'unet-a.safetensors' },
|
||||
{ id: 1002, type: 'Model', sizeKB: 2048, name: 'file-b.safetensors' },
|
||||
{ id: 1003, type: 'Model', sizeKB: 2048, name: 'file-c.safetensors' },
|
||||
],
|
||||
})];
|
||||
|
||||
manager.showFileSelectionStep('201');
|
||||
|
||||
// Checking a regular Model file disables the UNet option
|
||||
getFileOption('1002').click();
|
||||
expect(getFileOption('1001').classList.contains('group-disabled')).toBe(true);
|
||||
expect(getFileOption('1001').querySelector('input[type="checkbox"]').disabled).toBe(true);
|
||||
expect(getFileOption('1003').classList.contains('group-disabled')).toBe(false);
|
||||
|
||||
// Clicking a group-disabled option does nothing
|
||||
getFileOption('1001').click();
|
||||
expect(manager.selectedFiles.map(f => f.id)).toEqual([1002]);
|
||||
|
||||
// Unchecking everything re-enables the other group
|
||||
getFileOption('1002').click();
|
||||
expect(manager.selectedFiles).toHaveLength(0);
|
||||
expect(getFileOption('1001').classList.contains('group-disabled')).toBe(false);
|
||||
expect(getFileOption('1001').querySelector('input[type="checkbox"]').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps Next enabled for a partially downloaded multi-file version', () => {
|
||||
const manager = new DownloadManager();
|
||||
manager.currentVersion = makeMultiFileVersion({
|
||||
downloadedFiles: [
|
||||
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
|
||||
],
|
||||
});
|
||||
|
||||
manager.updateNextButtonState();
|
||||
|
||||
const nextButton = document.getElementById('nextFromVersion');
|
||||
expect(nextButton.disabled).toBe(false);
|
||||
expect(nextButton.classList.contains('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('disables Next when every weight file is already downloaded', () => {
|
||||
const manager = new DownloadManager();
|
||||
manager.currentVersion = makeMultiFileVersion({
|
||||
downloadedFiles: [
|
||||
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
|
||||
{ fileId: 1002, fileName: 'file-b.safetensors', filePath: '/models/loras/file-b.safetensors' },
|
||||
{ fileId: 1003, fileName: 'file-c.safetensors', filePath: '/models/loras/file-c.safetensors' },
|
||||
],
|
||||
});
|
||||
|
||||
manager.updateNextButtonState();
|
||||
|
||||
const nextButton = document.getElementById('nextFromVersion');
|
||||
expect(nextButton.disabled).toBe(true);
|
||||
expect(nextButton.classList.contains('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('disables Next for an in-library single-file version', () => {
|
||||
const manager = new DownloadManager();
|
||||
manager.currentVersion = {
|
||||
id: 202,
|
||||
name: 'Single-file version',
|
||||
files: [{ id: 1004, type: 'Model', sizeKB: 2048, name: 'file-d.safetensors' }],
|
||||
existsLocally: true,
|
||||
downloadedFiles: [],
|
||||
};
|
||||
|
||||
manager.updateNextButtonState();
|
||||
|
||||
const nextButton = document.getElementById('nextFromVersion');
|
||||
expect(nextButton.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Unit tests for per-file downloaded-state matching (#1058)."""
|
||||
|
||||
from py.routes.handlers.model_handlers import ModelCivitaiHandler
|
||||
|
||||
|
||||
VERSION = {
|
||||
"id": 42,
|
||||
"files": [
|
||||
{
|
||||
"id": 1001,
|
||||
"name": "file-a.safetensors",
|
||||
"hashes": {"SHA256": "AAA111"},
|
||||
},
|
||||
{
|
||||
"id": 1002,
|
||||
"name": "file-b.safetensors",
|
||||
"hashes": {"SHA256": "BBB222"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _entry(file_name: str, sha256: str = "", file_path: str | None = None):
|
||||
return {
|
||||
"file_name": file_name,
|
||||
"file_path": file_path or f"/models/{file_name}.safetensors",
|
||||
"sha256": sha256,
|
||||
}
|
||||
|
||||
|
||||
def test_matches_by_sha256():
|
||||
entries = [_entry("renamed-locally", "bbb222")]
|
||||
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
|
||||
assert result == [
|
||||
{
|
||||
"fileId": 1002,
|
||||
"fileName": "file-b.safetensors",
|
||||
"filePath": "/models/renamed-locally.safetensors",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_falls_back_to_name_when_hash_missing():
|
||||
entries = [_entry("file-a", "")]
|
||||
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
|
||||
assert [r["fileId"] for r in result] == [1001]
|
||||
|
||||
|
||||
def test_hash_takes_precedence_over_name():
|
||||
# Hash points at file-b while the name points at file-a: hash wins.
|
||||
entries = [_entry("file-a", "bbb222")]
|
||||
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
|
||||
assert [r["fileId"] for r in result] == [1002]
|
||||
|
||||
|
||||
def test_unmatched_entries_are_skipped():
|
||||
entries = [
|
||||
_entry("unrelated", "ccc333"),
|
||||
_entry("file-b", ""), # name match
|
||||
]
|
||||
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
|
||||
assert [r["fileId"] for r in result] == [1002]
|
||||
|
||||
|
||||
def test_multiple_files_of_same_version():
|
||||
entries = [
|
||||
_entry("file-a", "aaa111"),
|
||||
_entry("file-b", "bbb222"),
|
||||
]
|
||||
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
|
||||
assert [r["fileId"] for r in result] == [1001, 1002]
|
||||
|
||||
|
||||
def test_empty_inputs():
|
||||
assert ModelCivitaiHandler._match_downloaded_files(VERSION, []) == []
|
||||
assert ModelCivitaiHandler._match_downloaded_files({"id": 1}, [_entry("x")]) == []
|
||||
assert ModelCivitaiHandler._match_downloaded_files(VERSION, None) == []
|
||||
@@ -68,3 +68,94 @@ async def test_download_history_bulk_lookup(tmp_path: Path) -> None:
|
||||
5: {501, 502},
|
||||
6: {601},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_file_history_tracking(tmp_path: Path) -> None:
|
||||
"""Per-file records coexist with the version-level row (#1058)."""
|
||||
db_path = tmp_path / "download-history.sqlite"
|
||||
service = DownloadedVersionHistoryService(
|
||||
str(db_path),
|
||||
settings_manager=DummySettings(),
|
||||
)
|
||||
|
||||
await service.mark_downloaded(
|
||||
"lora", 101, model_id=11, source="download",
|
||||
file_path="/models/a.safetensors", file_id=1001, file_name="a.safetensors",
|
||||
)
|
||||
await service.mark_downloaded(
|
||||
"lora", 101, model_id=11, source="download",
|
||||
file_path="/models/b.safetensors", file_id=1002, file_name="b.safetensors",
|
||||
)
|
||||
|
||||
assert await service.get_downloaded_file_ids("lora", 101) == [1001, 1002]
|
||||
# Version-level tracking remains single-row per version
|
||||
assert await service.get_downloaded_version_ids("lora", 11) == [101]
|
||||
|
||||
# Re-downloading the same file updates in place, no duplicate
|
||||
await service.mark_downloaded(
|
||||
"lora", 101, source="download", file_id=1001, file_name="a.safetensors",
|
||||
)
|
||||
assert await service.get_downloaded_file_ids("lora", 101) == [1001, 1002]
|
||||
|
||||
# Single-file deletion keeps the sibling record
|
||||
await service.mark_file_deleted("lora", 101, 1001)
|
||||
assert await service.get_downloaded_file_ids("lora", 101) == [1002]
|
||||
|
||||
# Whole-version deletion clears per-file records
|
||||
await service.mark_as_deleted("lora", 101)
|
||||
assert await service.get_downloaded_file_ids("lora", 101) == []
|
||||
assert await service.has_been_downloaded("lora", 101) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_file_history_ignores_invalid_ids(tmp_path: Path) -> None:
|
||||
service = DownloadedVersionHistoryService(
|
||||
str(tmp_path / "download-history.sqlite"),
|
||||
settings_manager=DummySettings(),
|
||||
)
|
||||
|
||||
# mark_downloaded without a file id only touches the version-level table
|
||||
await service.mark_downloaded("lora", 201, model_id=21, source="scan")
|
||||
assert await service.get_downloaded_file_ids("lora", 201) == []
|
||||
|
||||
# Invalid inputs are no-ops
|
||||
await service.mark_file_deleted("lora", 201, None) # type: ignore[arg-type]
|
||||
assert await service.get_downloaded_file_ids("unknown-type", 201) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_history_table_created_for_legacy_db(tmp_path: Path) -> None:
|
||||
"""Existing databases gain the per-file table via CREATE IF NOT EXISTS."""
|
||||
import sqlite3
|
||||
|
||||
db_path = tmp_path / "download-history.sqlite"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE downloaded_model_versions (
|
||||
model_type TEXT NOT NULL,
|
||||
version_id INTEGER NOT NULL,
|
||||
model_id INTEGER,
|
||||
first_seen_at REAL NOT NULL,
|
||||
last_seen_at REAL NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
last_file_path TEXT,
|
||||
last_library_name TEXT,
|
||||
is_deleted_override INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (model_type, version_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.close()
|
||||
|
||||
service = DownloadedVersionHistoryService(
|
||||
str(db_path),
|
||||
settings_manager=DummySettings(),
|
||||
)
|
||||
await service.mark_downloaded(
|
||||
"lora", 301, model_id=31, source="download",
|
||||
file_id=9001, file_name="file.safetensors",
|
||||
)
|
||||
assert await service.get_downloaded_file_ids("lora", 301) == [9001]
|
||||
assert await service.has_been_downloaded("lora", 301) is True
|
||||
|
||||
@@ -61,3 +61,87 @@ async def test_model_cache_tracks_versions_by_model_id():
|
||||
assert cache.get_versions_by_model_id(2) == [
|
||||
{'versionId': 201, 'name': 'Gamma', 'fileName': 'model-b'},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_files_index_tracks_multiple_files_per_version():
|
||||
"""Two downloaded files of the same version both stay indexed (#1058)."""
|
||||
item_a = {
|
||||
'file_path': '/models/v1-a.safetensors',
|
||||
'file_name': 'model-v1-a',
|
||||
'folder': '',
|
||||
'civitai': {'id': 301, 'modelId': 3, 'name': 'Multi'},
|
||||
}
|
||||
item_b = {
|
||||
'file_path': '/models/v1-b.safetensors',
|
||||
'file_name': 'model-v1-b',
|
||||
'folder': '',
|
||||
'civitai': {'id': 301, 'modelId': 3, 'name': 'Multi'},
|
||||
}
|
||||
|
||||
cache = ModelCache(
|
||||
raw_data=[item_a, item_b],
|
||||
folders=[],
|
||||
name_display_mode='model_name',
|
||||
)
|
||||
|
||||
files = cache.get_files_by_version_id(301)
|
||||
assert {f['file_path'] for f in files} == {
|
||||
'/models/v1-a.safetensors',
|
||||
'/models/v1-b.safetensors',
|
||||
}
|
||||
|
||||
# Re-adding an existing entry must not duplicate it
|
||||
cache.add_to_version_index(item_a)
|
||||
assert len(cache.get_files_by_version_id(301)) == 2
|
||||
|
||||
# Removing the indexed file re-points version_index to the sibling
|
||||
indexed = cache.version_index[301]
|
||||
sibling = item_b if indexed is item_a else item_a
|
||||
cache.remove_from_version_index(indexed)
|
||||
|
||||
assert 301 in cache.version_index
|
||||
assert cache.version_index[301]['file_path'] == sibling['file_path']
|
||||
assert cache.get_versions_by_model_id(3) == [
|
||||
{'versionId': 301, 'name': 'Multi', 'fileName': sibling['file_name']},
|
||||
]
|
||||
remaining = cache.get_files_by_version_id(301)
|
||||
assert [f['file_path'] for f in remaining] == [sibling['file_path']]
|
||||
|
||||
# Removing the last file drops the version from all indexes
|
||||
cache.remove_from_version_index(sibling)
|
||||
assert 301 not in cache.version_index
|
||||
assert cache.get_files_by_version_id(301) == []
|
||||
assert cache.get_versions_by_model_id(3) == []
|
||||
assert 3 not in cache.model_id_index
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_files_index_rebuild_from_raw_data():
|
||||
"""rebuild_version_index reconstructs the multi-valued index (#1058)."""
|
||||
item_a = {
|
||||
'file_path': '/models/v1-a.safetensors',
|
||||
'file_name': 'model-v1-a',
|
||||
'folder': '',
|
||||
'civitai': {'id': 401, 'modelId': 4, 'name': 'Multi'},
|
||||
}
|
||||
item_b = {
|
||||
'file_path': '/models/v1-b.safetensors',
|
||||
'file_name': 'model-v1-b',
|
||||
'folder': '',
|
||||
'civitai': {'id': 401, 'modelId': 4, 'name': 'Multi'},
|
||||
}
|
||||
|
||||
cache = ModelCache(
|
||||
raw_data=[item_a, item_b],
|
||||
folders=[],
|
||||
name_display_mode='model_name',
|
||||
)
|
||||
|
||||
cache.version_files_index = {}
|
||||
cache.rebuild_version_index()
|
||||
|
||||
assert len(cache.get_files_by_version_id(401)) == 2
|
||||
# Invalid ids normalize to empty results
|
||||
assert cache.get_files_by_version_id('not-an-int') == []
|
||||
assert cache.get_files_by_version_id(None) == []
|
||||
|
||||
Reference in New Issue
Block a user