mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-20 12:31:27 -03:00
fix(download): allow downloading additional files of an in-library model version (#1058)
This commit is contained in:
@@ -82,14 +82,18 @@ def stub_metadata(monkeypatch):
|
||||
|
||||
|
||||
class DummyScanner:
|
||||
def __init__(self, exists: bool = False):
|
||||
def __init__(self, exists: bool = False, raw_data=None):
|
||||
self.exists = exists
|
||||
self.calls = []
|
||||
self._cache = SimpleNamespace(raw_data=list(raw_data or []))
|
||||
|
||||
async def check_model_version_exists(self, version_id):
|
||||
self.calls.append(version_id)
|
||||
return self.exists
|
||||
|
||||
async def get_cached_data(self):
|
||||
return self._cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scanners(monkeypatch):
|
||||
@@ -1692,3 +1696,310 @@ async def test_download_proceeds_when_history_skip_disabled(monkeypatch, scanner
|
||||
assert result.get("skipped") is not True
|
||||
history_service.has_been_downloaded.assert_not_called()
|
||||
execute_download.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-file downloads within a single model version (#1058)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _multi_file_payload(include_hashes: bool = True):
|
||||
"""Version payload with two weight files under the same version."""
|
||||
files = [
|
||||
{
|
||||
"id": 1001,
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"downloadUrl": "https://example.invalid/file-a.safetensors",
|
||||
"name": "file-a.safetensors",
|
||||
},
|
||||
{
|
||||
"id": 1002,
|
||||
"type": "Model",
|
||||
"primary": False,
|
||||
"downloadUrl": "https://example.invalid/file-b.safetensors",
|
||||
"name": "file-b.safetensors",
|
||||
},
|
||||
]
|
||||
if include_hashes:
|
||||
files[0]["hashes"] = {"SHA256": "AAA111"}
|
||||
files[1]["hashes"] = {"SHA256": "BBB222"}
|
||||
return {
|
||||
"id": 42,
|
||||
"modelId": 7,
|
||||
"model": {"type": "LoRA", "tags": ["fantasy"]},
|
||||
"baseModel": "BaseModel",
|
||||
"creator": {"username": "Author"},
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def _local_entry(file_name: str, sha256: str, version_id: int = 42):
|
||||
"""A library cache entry for one already-downloaded file of a version."""
|
||||
return {
|
||||
"file_name": file_name,
|
||||
"file_path": f"/tmp/{file_name}.safetensors",
|
||||
"sha256": sha256,
|
||||
"civitai": {"id": version_id, "modelId": 7},
|
||||
}
|
||||
|
||||
|
||||
def _stub_history_service(monkeypatch, has_been_downloaded: bool = False):
|
||||
history_service = AsyncMock()
|
||||
history_service.has_been_downloaded = AsyncMock(
|
||||
return_value=has_been_downloaded
|
||||
)
|
||||
history_service.mark_downloaded = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
ServiceRegistry,
|
||||
"get_downloaded_version_history_service",
|
||||
AsyncMock(return_value=history_service),
|
||||
)
|
||||
return history_service
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_allows_other_file_of_in_library_version(
|
||||
monkeypatch, scanners, metadata_provider
|
||||
):
|
||||
"""A different file of an in-library version must still download (#1058)."""
|
||||
scanners.lora._cache.raw_data.append(_local_entry("file-a", "aaa111"))
|
||||
metadata_provider.get_model_version = AsyncMock(
|
||||
return_value=_multi_file_payload()
|
||||
)
|
||||
_stub_history_service(monkeypatch)
|
||||
|
||||
execute_download = AsyncMock(return_value={"success": True, "download_id": "done"})
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", execute_download, raising=False
|
||||
)
|
||||
|
||||
manager = DownloadManager()
|
||||
result = await manager.download_from_civitai(
|
||||
model_version_id=42,
|
||||
save_dir="/tmp",
|
||||
file_params={"id": 1002, "type": "Model", "name": "file-b.safetensors"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
execute_download.assert_awaited_once()
|
||||
# Version-level gates must not run for an explicit file selection
|
||||
assert scanners.lora.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_blocks_same_file_of_in_library_version(
|
||||
monkeypatch, scanners, metadata_provider
|
||||
):
|
||||
"""Re-downloading the SAME file of an in-library version stays blocked."""
|
||||
scanners.lora._cache.raw_data.append(_local_entry("file-a", "aaa111"))
|
||||
metadata_provider.get_model_version = AsyncMock(
|
||||
return_value=_multi_file_payload()
|
||||
)
|
||||
_stub_history_service(monkeypatch)
|
||||
|
||||
execute_download = AsyncMock(return_value={"success": True})
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", execute_download, raising=False
|
||||
)
|
||||
|
||||
manager = DownloadManager()
|
||||
result = await manager.download_from_civitai(
|
||||
model_version_id=42,
|
||||
save_dir="/tmp",
|
||||
file_params={"id": 1001, "type": "Model", "name": "file-a.safetensors"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "file-a.safetensors" in result["error"]
|
||||
assert "already exists in lora library" in result["error"]
|
||||
execute_download.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_unresolvable_file_params_falls_back_to_version_gate(
|
||||
monkeypatch, scanners, metadata_provider
|
||||
):
|
||||
"""file_params that match no file must not bypass version-level gates."""
|
||||
scanners.lora.exists = True
|
||||
metadata_provider.get_model_version = AsyncMock(
|
||||
return_value=_multi_file_payload()
|
||||
)
|
||||
_stub_history_service(monkeypatch)
|
||||
|
||||
execute_download = AsyncMock(return_value={"success": True})
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", execute_download, raising=False
|
||||
)
|
||||
|
||||
manager = DownloadManager()
|
||||
result = await manager.download_from_civitai(
|
||||
model_version_id=42,
|
||||
save_dir="/tmp",
|
||||
file_params={"id": 9999, "type": "Model"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "Model version already exists in lora library"
|
||||
execute_download.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_empty_file_params_treated_as_no_selection(
|
||||
monkeypatch, scanners, metadata_provider
|
||||
):
|
||||
"""An empty file_params dict is normalized to None (version-level gates)."""
|
||||
scanners.lora.exists = True
|
||||
|
||||
execute_download = AsyncMock(return_value={"success": True})
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", execute_download, raising=False
|
||||
)
|
||||
|
||||
manager = DownloadManager()
|
||||
result = await manager.download_from_civitai(
|
||||
model_version_id=42,
|
||||
save_dir="/tmp",
|
||||
file_params={},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "Model version already exists in lora library"
|
||||
# Early version-level gate fired (file_params normalized to None)
|
||||
assert scanners.lora.calls == [42]
|
||||
execute_download.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_explicit_file_bypasses_history_skip(
|
||||
monkeypatch, scanners, metadata_provider
|
||||
):
|
||||
"""Explicit file selection bypasses the previously-downloaded skip."""
|
||||
get_settings_manager().settings[
|
||||
"skip_previously_downloaded_model_versions"
|
||||
] = True
|
||||
scanners.lora._cache.raw_data.append(_local_entry("file-a", "aaa111"))
|
||||
metadata_provider.get_model_version = AsyncMock(
|
||||
return_value=_multi_file_payload()
|
||||
)
|
||||
history_service = _stub_history_service(monkeypatch, has_been_downloaded=True)
|
||||
|
||||
execute_download = AsyncMock(return_value={"success": True, "download_id": "done"})
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", execute_download, raising=False
|
||||
)
|
||||
|
||||
manager = DownloadManager()
|
||||
result = await manager.download_from_civitai(
|
||||
model_version_id=42,
|
||||
save_dir="/tmp",
|
||||
file_params={"id": 1002, "type": "Model", "name": "file-b.safetensors"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
execute_download.assert_awaited_once()
|
||||
# History gate is bypassed before it even queries the service
|
||||
history_service.has_been_downloaded.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_match_by_name_when_local_hash_missing(
|
||||
monkeypatch, scanners, metadata_provider
|
||||
):
|
||||
"""Legacy local metadata without sha256 falls back to name matching."""
|
||||
scanners.lora._cache.raw_data.append(_local_entry("file-a", ""))
|
||||
metadata_provider.get_model_version = AsyncMock(
|
||||
return_value=_multi_file_payload()
|
||||
)
|
||||
_stub_history_service(monkeypatch)
|
||||
|
||||
execute_download = AsyncMock(return_value={"success": True})
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", execute_download, raising=False
|
||||
)
|
||||
|
||||
manager = DownloadManager()
|
||||
result = await manager.download_from_civitai(
|
||||
model_version_id=42,
|
||||
save_dir="/tmp",
|
||||
file_params={"id": 1001, "type": "Model"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "already exists in lora library" in result["error"]
|
||||
execute_download.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_no_false_positive_when_both_hashes_empty(
|
||||
monkeypatch, scanners, metadata_provider
|
||||
):
|
||||
"""Two empty hashes must never compare equal; name decides instead."""
|
||||
# Local entry for a DIFFERENT file of the same version, no hash stored
|
||||
scanners.lora._cache.raw_data.append(_local_entry("file-b", ""))
|
||||
metadata_provider.get_model_version = AsyncMock(
|
||||
return_value=_multi_file_payload(include_hashes=False)
|
||||
)
|
||||
_stub_history_service(monkeypatch)
|
||||
|
||||
execute_download = AsyncMock(return_value={"success": True, "download_id": "done"})
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", execute_download, raising=False
|
||||
)
|
||||
|
||||
manager = DownloadManager()
|
||||
result = await manager.download_from_civitai(
|
||||
model_version_id=42,
|
||||
save_dir="/tmp",
|
||||
file_params={"id": 1001, "type": "Model"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
execute_download.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_model_id_only_with_file_params_resolves_file(
|
||||
monkeypatch, scanners, metadata_provider
|
||||
):
|
||||
"""model_id-only requests with file_params resolve the file post-fetch."""
|
||||
scanners.lora.exists = True # version-level index says "in library"
|
||||
scanners.lora._cache.raw_data.append(_local_entry("file-a", "aaa111"))
|
||||
metadata_provider.get_model_version = AsyncMock(
|
||||
return_value=_multi_file_payload()
|
||||
)
|
||||
_stub_history_service(monkeypatch)
|
||||
|
||||
execute_download = AsyncMock(return_value={"success": True, "download_id": "done"})
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", execute_download, raising=False
|
||||
)
|
||||
|
||||
manager = DownloadManager()
|
||||
result = await manager.download_from_civitai(
|
||||
model_id=7,
|
||||
save_dir="/tmp",
|
||||
file_params={"id": 1002, "type": "Model"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
execute_download.assert_awaited_once()
|
||||
|
||||
|
||||
def test_resolve_target_file_by_id():
|
||||
files = _multi_file_payload()["files"]
|
||||
resolved = DownloadManager._resolve_target_file(files, {"id": 1002})
|
||||
assert resolved is files[1]
|
||||
|
||||
|
||||
def test_resolve_target_file_by_primary_flag():
|
||||
files = _multi_file_payload()["files"]
|
||||
resolved = DownloadManager._resolve_target_file(files, {"isPrimary": True})
|
||||
assert resolved is files[0]
|
||||
|
||||
|
||||
def test_resolve_target_file_returns_none_for_no_match():
|
||||
files = _multi_file_payload()["files"]
|
||||
assert DownloadManager._resolve_target_file(files, {"id": 9999}) is None
|
||||
assert DownloadManager._resolve_target_file(files, None) is None
|
||||
assert DownloadManager._resolve_target_file(files, {}) is None
|
||||
|
||||
@@ -5,6 +5,8 @@ Covers the new ``download_id``-based code paths in
|
||||
compatibility with ``id``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -191,3 +193,131 @@ async def test_retry_unknown_download_id(tmp_path: Path) -> None:
|
||||
|
||||
item = await svc.retry_from_history(download_id="dl-nope")
|
||||
assert item is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# file_params persistence across queue -> history -> retry (#1058)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_params_survive_queue_to_history(tmp_path: Path) -> None:
|
||||
"""complete_download copies the queue row's file_params into history."""
|
||||
svc = _make_service(tmp_path)
|
||||
await svc.add_to_queue(
|
||||
download_id="dl-fp",
|
||||
model_id=1,
|
||||
model_version_id=100,
|
||||
file_params={"id": 1002, "type": "Model"},
|
||||
)
|
||||
|
||||
await svc.complete_download("dl-fp", status="failed", error="boom")
|
||||
|
||||
history = await svc.get_history()
|
||||
assert len(history["items"]) == 1
|
||||
assert json.loads(history["items"][0]["file_params"]) == {
|
||||
"id": 1002,
|
||||
"type": "Model",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_restores_file_params(tmp_path: Path) -> None:
|
||||
"""retry_from_history re-queues with the originally selected file."""
|
||||
svc = _make_service(tmp_path)
|
||||
await svc.add_to_history(
|
||||
download_id="dl-fail-fp",
|
||||
model_id=1,
|
||||
model_version_id=100,
|
||||
status="failed",
|
||||
file_params={"id": 1002, "type": "Model"},
|
||||
)
|
||||
|
||||
item = await svc.retry_from_history(download_id="dl-fail-fp")
|
||||
|
||||
assert item is not None
|
||||
assert item["status"] == "queued"
|
||||
assert json.loads(item["file_params"]) == {"id": 1002, "type": "Model"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_all_restores_file_params(tmp_path: Path) -> None:
|
||||
"""retry_all_failed preserves file_params for every re-queued item."""
|
||||
svc = _make_service(tmp_path)
|
||||
await svc.add_to_history(
|
||||
download_id="dl-f1", status="failed", file_params={"id": 1001}
|
||||
)
|
||||
await svc.add_to_history(download_id="dl-f2", status="canceled")
|
||||
|
||||
count = await svc.retry_all_failed()
|
||||
assert count == 2
|
||||
|
||||
queue = await svc.get_queue()
|
||||
restored = sorted(
|
||||
(q["file_params"] or "") for q in queue
|
||||
)
|
||||
assert restored[0] == "" # dl-f2 never had file_params
|
||||
assert json.loads(restored[1]) == {"id": 1001}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_history_db_gains_file_params_column(tmp_path: Path) -> None:
|
||||
"""Databases created before the file_params column get migrated (#1058)."""
|
||||
db_path = tmp_path / "queue.sqlite"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE download_queue (
|
||||
download_id TEXT PRIMARY KEY,
|
||||
model_id INTEGER,
|
||||
model_version_id INTEGER,
|
||||
model_name TEXT NOT NULL DEFAULT '',
|
||||
version_name TEXT DEFAULT '',
|
||||
thumbnail_url TEXT DEFAULT '',
|
||||
source TEXT,
|
||||
file_params TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
priority INTEGER DEFAULT 0,
|
||||
progress INTEGER DEFAULT 0,
|
||||
bytes_downloaded INTEGER DEFAULT 0,
|
||||
total_bytes INTEGER,
|
||||
bytes_per_second REAL DEFAULT 0.0,
|
||||
error TEXT,
|
||||
file_path TEXT,
|
||||
added_at REAL NOT NULL,
|
||||
started_at REAL,
|
||||
completed_at REAL
|
||||
);
|
||||
CREATE TABLE download_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
download_id TEXT,
|
||||
model_id INTEGER,
|
||||
model_version_id INTEGER,
|
||||
model_name TEXT NOT NULL DEFAULT '',
|
||||
version_name TEXT DEFAULT '',
|
||||
thumbnail_url TEXT DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
file_path TEXT,
|
||||
bytes_downloaded INTEGER DEFAULT 0,
|
||||
total_bytes INTEGER,
|
||||
completed_at REAL NOT NULL,
|
||||
is_already_exists INTEGER DEFAULT 0
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.close()
|
||||
|
||||
svc = DownloadQueueService(db_path=str(db_path))
|
||||
|
||||
# The migrated table accepts file_params writes
|
||||
await svc.add_to_history(
|
||||
download_id="dl-legacy-fp", status="failed", file_params={"id": 5}
|
||||
)
|
||||
history = await svc.get_history()
|
||||
assert json.loads(history["items"][0]["file_params"]) == {"id": 5}
|
||||
|
||||
# And retry restores them
|
||||
item = await svc.retry_from_history(download_id="dl-legacy-fp")
|
||||
assert item is not None
|
||||
assert json.loads(item["file_params"]) == {"id": 5}
|
||||
|
||||
Reference in New Issue
Block a user