feat(download): per-file download status and multi-file selection (#1058)

This commit is contained in:
Will Miao
2026-08-19 17:51:31 +08:00
parent cef4129fc9
commit e7c26bf722
23 changed files with 22259 additions and 21031 deletions
+2331 -2327
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2331 -2327
View File
File diff suppressed because it is too large Load Diff
+2331 -2327
View File
File diff suppressed because it is too large Load Diff
+2331 -2327
View File
File diff suppressed because it is too large Load Diff
+2331 -2327
View File
File diff suppressed because it is too large Load Diff
+2331 -2327
View File
File diff suppressed because it is too large Load Diff
+2331 -2327
View File
File diff suppressed because it is too large Load Diff
+2331 -2327
View File
File diff suppressed because it is too large Load Diff
+26 -9
View File
@@ -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:
+71
View File
@@ -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")
+14
View File
@@ -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)
+65 -1
View File
@@ -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:
+33
View File
@@ -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;
+55 -39
View File
@@ -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, {
+312 -38
View File
@@ -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
+84
View File
@@ -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) == []