mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-08 23:10:15 -03:00
feat(metadata): resolve AutoV3 at download time without waiting for backfill
- Read AutoV3 directly from the downloaded file's own file_info hashes (no SHA256 cross-matching against version_info.files, so the value is captured even when the API omits SHA256) - Extract normalize_autov3() validation helper shared with the sha256-matching autov3_from_civitai_files path - Fall back to the embedded safetensors header hash at download completion; mark '' (checked-unavailable) so the startup backfill query (autov3 IS NULL) never revisits the row - Clear archive-level AutoV3 for zip-extracted models so per-file header resolution applies to every extracted model
This commit is contained in:
@@ -18,7 +18,7 @@ from ..utils.constants import (
|
||||
VALID_LORA_TYPES,
|
||||
)
|
||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||
from ..utils.file_utils import calculate_sha256
|
||||
from ..utils.file_utils import calculate_sha256, calculate_autov3
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from ..utils.utils import sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
@@ -2160,6 +2160,10 @@ class DownloadManager:
|
||||
"error": f"Zip archive does not contain any supported model files ({supported_text})",
|
||||
}
|
||||
actual_file_paths = extracted_paths
|
||||
# The archive entry's AutoV3 (if any) describes the zip itself,
|
||||
# not the extracted models; clear it so per-file header
|
||||
# resolution applies to every extracted model.
|
||||
metadata.autov3 = None
|
||||
try:
|
||||
os.remove(save_path)
|
||||
except OSError as exc:
|
||||
@@ -2374,6 +2378,16 @@ class DownloadManager:
|
||||
sha256 = await calculate_sha256(file_path)
|
||||
if sha256:
|
||||
entry.sha256 = sha256.lower()
|
||||
# AutoV3: the Civitai-reported value for the downloaded file (set
|
||||
# by from_civitai_info) takes precedence. Only the un-checked
|
||||
# state (None) triggers a header read; '' (checked-unavailable)
|
||||
# is never re-read, honoring the three-state contract so rows
|
||||
# marked at download time stay untouched by later passes.
|
||||
if entry.autov3 is None:
|
||||
autov3 = await asyncio.get_running_loop().run_in_executor(
|
||||
None, calculate_autov3, file_path
|
||||
)
|
||||
entry.autov3 = (autov3 or "").lower()
|
||||
entries.append(entry)
|
||||
|
||||
return entries
|
||||
|
||||
@@ -6,15 +6,29 @@ from .constants import INVALID_AUTOV3_EMPTY_HASH
|
||||
from .model_utils import determine_base_model
|
||||
|
||||
|
||||
def normalize_autov3(value: Any) -> Optional[str]:
|
||||
"""Normalize a raw Civitai AutoV3 value to the canonical 12-char form.
|
||||
|
||||
Returns the first 12 characters, lowercased, when ``value`` is a string
|
||||
of at least 12 characters. The empty-string SHA256 placeholder
|
||||
(``e3b0c44298fc``) is rejected — it is a repackaging-tool artifact, not a
|
||||
real hash.
|
||||
|
||||
Returns ``None`` when the value is unusable.
|
||||
"""
|
||||
if isinstance(value, str) and len(value) >= 12:
|
||||
candidate = value[:12].lower()
|
||||
if candidate != INVALID_AUTOV3_EMPTY_HASH:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def autov3_from_civitai_files(civitai_data: Optional[Dict], sha256: str) -> Optional[str]:
|
||||
"""Extract the AutoV3 hash from Civitai metadata for the matching file.
|
||||
|
||||
Civitai versions can ship multiple files; the AutoV3 hash is only valid
|
||||
for the file whose ``hashes.SHA256`` equals the local model's sha256.
|
||||
Matching is case-insensitive. The value is the first 12 characters of
|
||||
Civitai's AutoV3 hash, lowercased. The empty-string SHA256 placeholder
|
||||
(``e3b0c44298fc``) is rejected — it is a repackaging-tool artifact, not a
|
||||
real hash.
|
||||
Matching is case-insensitive.
|
||||
|
||||
Returns ``None`` when no Civitai data, no matching file, or no usable
|
||||
AutoV3 hash is available.
|
||||
@@ -28,11 +42,7 @@ def autov3_from_civitai_files(civitai_data: Optional[Dict], sha256: str) -> Opti
|
||||
hashes = file_info.get("hashes") or {}
|
||||
file_sha = (hashes.get("SHA256") or "").lower()
|
||||
if file_sha and file_sha == target_sha:
|
||||
auto_v3 = hashes.get("AutoV3")
|
||||
if isinstance(auto_v3, str) and len(auto_v3) >= 12:
|
||||
candidate = auto_v3[:12].lower()
|
||||
if candidate != INVALID_AUTOV3_EMPTY_HASH:
|
||||
return candidate
|
||||
return normalize_autov3(hashes.get("AutoV3"))
|
||||
return None
|
||||
|
||||
|
||||
@@ -264,7 +274,8 @@ class LoraMetadata(BaseModelMetadata):
|
||||
civitai=version_info,
|
||||
tags=tags,
|
||||
modelDescription=description,
|
||||
autov3=autov3_from_civitai_files(version_info, sha256_value),
|
||||
# Direct read: the downloaded file IS file_info, no SHA256 matching.
|
||||
autov3=normalize_autov3((file_info.get("hashes") or {}).get("AutoV3")),
|
||||
)
|
||||
|
||||
|
||||
@@ -308,7 +319,8 @@ class CheckpointMetadata(BaseModelMetadata):
|
||||
sub_type=sub_type,
|
||||
tags=tags,
|
||||
modelDescription=description,
|
||||
autov3=autov3_from_civitai_files(version_info, sha256_value),
|
||||
# Direct read: the downloaded file IS file_info, no SHA256 matching.
|
||||
autov3=normalize_autov3((file_info.get("hashes") or {}).get("AutoV3")),
|
||||
)
|
||||
|
||||
|
||||
@@ -352,5 +364,6 @@ class EmbeddingMetadata(BaseModelMetadata):
|
||||
sub_type=sub_type,
|
||||
tags=tags,
|
||||
modelDescription=description,
|
||||
autov3=autov3_from_civitai_files(version_info, sha256_value),
|
||||
# Direct read: the downloaded file IS file_info, no SHA256 matching.
|
||||
autov3=normalize_autov3((file_info.get("hashes") or {}).get("AutoV3")),
|
||||
)
|
||||
|
||||
100
tests/services/test_download_manager_autov3.py
Normal file
100
tests/services/test_download_manager_autov3.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""AutoV3 resolution in the download completion path (``_build_metadata_entries``).
|
||||
|
||||
Covers the Civitai-first / local-header fallback contract: a downloaded file
|
||||
whose Civitai file_info reports no AutoV3 gets the embedded safetensors header
|
||||
hash resolved right away (instead of waiting for the next startup's backfill),
|
||||
and a file with no usable header is marked ``''`` (checked but unavailable).
|
||||
"""
|
||||
|
||||
import json
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.download_manager import DownloadManager
|
||||
from py.utils.models import LoraMetadata
|
||||
|
||||
|
||||
def _write_safetensors(path, metadata, payload=b"payload-bytes"):
|
||||
"""Write a minimal real safetensors file: 8-byte little-endian header
|
||||
length, a JSON header containing ``__metadata__``, then arbitrary payload."""
|
||||
header = json.dumps({"__metadata__": metadata}).encode("utf-8")
|
||||
path.write_bytes(struct.pack("<Q", len(header)) + header + payload)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager():
|
||||
# _build_metadata_entries touches no instance state, so a bare instance is
|
||||
# enough and avoids the DownloadManager singleton's heavy dependencies.
|
||||
return DownloadManager.__new__(DownloadManager)
|
||||
|
||||
|
||||
def _lora_metadata(file_path: str, autov3=None):
|
||||
return LoraMetadata(
|
||||
file_name="model",
|
||||
model_name="Model",
|
||||
file_path=str(file_path),
|
||||
size=0,
|
||||
modified=0.0,
|
||||
sha256="abc123",
|
||||
base_model="SDXL",
|
||||
preview_url="",
|
||||
autov3=autov3,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keeps_civitai_reported_autov3(manager, tmp_path):
|
||||
file_path = tmp_path / "model.safetensors"
|
||||
_write_safetensors(file_path, {"sshs_model_hash": "ffffffffffffffffffff"})
|
||||
metadata = _lora_metadata(file_path, autov3="abcdef123456")
|
||||
|
||||
entries = await manager._build_metadata_entries(metadata, [str(file_path)])
|
||||
|
||||
assert entries[0].autov3 == "abcdef123456"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolves_header_autov3_when_civitai_missing(manager, tmp_path):
|
||||
file_path = tmp_path / "model.safetensors"
|
||||
_write_safetensors(file_path, {"sshs_model_hash": "ABCDEF1234567890ABCDEF"})
|
||||
metadata = _lora_metadata(file_path, autov3=None)
|
||||
|
||||
entries = await manager._build_metadata_entries(metadata, [str(file_path)])
|
||||
|
||||
assert entries[0].autov3 == "abcdef123456"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_marks_checked_unavailable_when_no_header_hash(manager, tmp_path):
|
||||
file_path = tmp_path / "model.ckpt"
|
||||
file_path.write_bytes(b"just some plain bytes, not a safetensors file")
|
||||
metadata = _lora_metadata(file_path, autov3=None)
|
||||
|
||||
entries = await manager._build_metadata_entries(metadata, [str(file_path)])
|
||||
|
||||
assert entries[0].autov3 == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_retry_checked_unavailable(manager, tmp_path, monkeypatch):
|
||||
# '' (checked but unavailable) must never trigger a header re-read: the
|
||||
# backfill query (autov3 IS NULL) already excludes such rows, and the
|
||||
# download-time guard must honor the same contract.
|
||||
file_path = tmp_path / "model.ckpt"
|
||||
file_path.write_bytes(b"bytes")
|
||||
metadata = _lora_metadata(file_path, autov3="")
|
||||
called = {"count": 0}
|
||||
|
||||
def _spy_calculate_autov3(_path):
|
||||
called["count"] += 1
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.services.download_manager.calculate_autov3", _spy_calculate_autov3
|
||||
)
|
||||
|
||||
entries = await manager._build_metadata_entries(metadata, [str(file_path)])
|
||||
|
||||
assert entries[0].autov3 == ""
|
||||
assert called["count"] == 0
|
||||
@@ -100,6 +100,7 @@ async def test_execute_download_uses_rewritten_civitai_preview(monkeypatch, tmp_
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.autov3 = None
|
||||
self.preview_nsfw_level = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
@@ -204,6 +205,7 @@ async def test_execute_download_respects_blur_setting(monkeypatch, tmp_path):
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.autov3 = None
|
||||
self.preview_nsfw_level = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
@@ -325,6 +327,7 @@ async def test_execute_download_uses_auth_for_red_civitai_downloads(monkeypatch,
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.autov3 = None
|
||||
self.preview_nsfw_level = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
|
||||
@@ -75,6 +75,7 @@ async def test_execute_download_retries_urls(monkeypatch, tmp_path):
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.autov3 = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return os.path.basename(self.file_path)
|
||||
@@ -164,6 +165,7 @@ async def test_execute_download_uses_aria2_backend_for_model_files(monkeypatch,
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.autov3 = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return os.path.basename(self.file_path)
|
||||
@@ -270,6 +272,7 @@ async def test_execute_download_allows_anonymous_civitai_with_aria2(
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.autov3 = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return os.path.basename(self.file_path)
|
||||
@@ -347,6 +350,7 @@ async def test_execute_download_adjusts_checkpoint_sub_type(monkeypatch, tmp_pat
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.autov3 = None
|
||||
self.preview_nsfw_level = 0
|
||||
self.sub_type = "checkpoint"
|
||||
|
||||
@@ -446,6 +450,7 @@ async def test_execute_download_extracts_zip_single_model(monkeypatch, tmp_path)
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.autov3 = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return os.path.basename(self.file_path)
|
||||
@@ -458,6 +463,9 @@ async def test_execute_download_extracts_zip_single_model(monkeypatch, tmp_path)
|
||||
return {"file_path": self.file_path}
|
||||
|
||||
metadata = DummyMetadata(zip_path)
|
||||
# Simulate a zip entry with an API-reported AutoV3: it must be cleared
|
||||
# for extracted models, which resolve their own header hash instead.
|
||||
metadata.autov3 = "zipzipzip123"
|
||||
version_info = {"images": []}
|
||||
download_urls = ["https://example.invalid/model.zip"]
|
||||
|
||||
@@ -503,6 +511,9 @@ async def test_execute_download_extracts_zip_single_model(monkeypatch, tmp_path)
|
||||
assert saved_call.args[0] == str(extracted)
|
||||
# SHA256 comes from metadata (API value), not recalculated
|
||||
assert saved_call.args[1].sha256 == "sha256"
|
||||
# The zip-level AutoV3 was cleared; the extracted file is not safetensors,
|
||||
# so the entry is marked checked-unavailable rather than inheriting it.
|
||||
assert saved_call.args[1].autov3 == ""
|
||||
assert dummy_scanner.add_model_to_cache.await_count == 1
|
||||
|
||||
|
||||
@@ -520,6 +531,7 @@ async def test_execute_download_extracts_zip_multiple_models(monkeypatch, tmp_pa
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.autov3 = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return os.path.basename(self.file_path)
|
||||
@@ -602,6 +614,7 @@ async def test_execute_download_extracts_zip_pt_embedding(monkeypatch, tmp_path)
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.autov3 = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return os.path.basename(self.file_path)
|
||||
@@ -1155,6 +1168,7 @@ async def test_execute_download_waits_for_paused_pre_transfer_gate(monkeypatch,
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.autov3 = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return os.path.basename(self.file_path)
|
||||
@@ -1264,6 +1278,7 @@ async def test_execute_download_reuses_existing_aria2_partial_path(monkeypatch,
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.autov3 = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
return "renamed.safetensors"
|
||||
@@ -1341,6 +1356,7 @@ async def test_execute_download_rejects_conflicting_aria2_partial_path(tmp_path)
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.autov3 = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
raise AssertionError("should not rename")
|
||||
@@ -1392,6 +1408,7 @@ async def test_execute_download_reassigns_same_aria2_partial_to_new_download_id(
|
||||
self.sha256 = "sha256"
|
||||
self.file_name = path.stem
|
||||
self.preview_url = None
|
||||
self.autov3 = None
|
||||
|
||||
def generate_unique_filename(self, *_args, **_kwargs):
|
||||
raise AssertionError("should not rename")
|
||||
|
||||
@@ -175,13 +175,14 @@ class TestBaseModelMetadataAutov3:
|
||||
|
||||
@pytest.mark.parametrize("model_cls", [LoraMetadata, CheckpointMetadata, EmbeddingMetadata])
|
||||
def test_from_civitai_info_extracts_autov3(self, model_cls):
|
||||
# Civitai versions can ship multiple files; AutoV3 is taken from the
|
||||
# file whose SHA256 matches the downloaded file.
|
||||
# The downloaded file is exactly ``file_info``, so AutoV3 is read
|
||||
# directly from its own hashes — no SHA256 cross-matching against
|
||||
# version_info.files (which may hold sibling files of the version).
|
||||
version_info = {
|
||||
"baseModel": "SDXL",
|
||||
"model": {"name": "Test", "description": "", "tags": []},
|
||||
"files": [
|
||||
{"name": "other.safetensors", "sizeKB": 100, "hashes": {"SHA256": "zzz999"}},
|
||||
{"name": "other.safetensors", "sizeKB": 100, "hashes": {"SHA256": "zzz999", "AutoV3": "999999999999"}},
|
||||
{
|
||||
"name": "model.safetensors",
|
||||
"sizeKB": 1000,
|
||||
@@ -192,7 +193,27 @@ class TestBaseModelMetadataAutov3:
|
||||
file_info = {
|
||||
"name": "model.safetensors",
|
||||
"sizeKB": 1000,
|
||||
"hashes": {"SHA256": "abc123"},
|
||||
"hashes": {"SHA256": "abc123", "AutoV3": "ABCDEF1234567890ABCDEF"},
|
||||
}
|
||||
|
||||
metadata = model_cls.from_civitai_info(version_info, file_info, "/test/model.safetensors")
|
||||
|
||||
assert metadata.autov3 == "abcdef123456"
|
||||
|
||||
@pytest.mark.parametrize("model_cls", [LoraMetadata, CheckpointMetadata, EmbeddingMetadata])
|
||||
def test_from_civitai_info_reads_autov3_without_sha256(self, model_cls):
|
||||
# The direct read must not depend on the API reporting SHA256 for the
|
||||
# file — AutoV3 alone suffices (the old SHA256-matching extraction
|
||||
# silently failed whenever hashes.SHA256 was missing).
|
||||
version_info = {
|
||||
"baseModel": "SDXL",
|
||||
"model": {"name": "Test", "description": "", "tags": []},
|
||||
"files": [],
|
||||
}
|
||||
file_info = {
|
||||
"name": "model.safetensors",
|
||||
"sizeKB": 1000,
|
||||
"hashes": {"AutoV3": "ABCDEF1234567890ABCDEF"},
|
||||
}
|
||||
|
||||
metadata = model_cls.from_civitai_info(version_info, file_info, "/test/model.safetensors")
|
||||
@@ -207,6 +228,7 @@ class TestBaseModelMetadataAutov3:
|
||||
{"SHA256": "abc123", "AutoV3": "abc"},
|
||||
{"SHA256": "abc123", "AutoV3": 123},
|
||||
{"SHA256": "abc123", "AutoV3": None},
|
||||
{"SHA256": "abc123", "AutoV3": "E3B0C44298FC1C149AFB..."},
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("model_cls", [LoraMetadata, CheckpointMetadata, EmbeddingMetadata])
|
||||
@@ -216,12 +238,22 @@ class TestBaseModelMetadataAutov3:
|
||||
"model": {"name": "Test", "description": "", "tags": []},
|
||||
"files": [{"name": "model.safetensors", "sizeKB": 1000, "hashes": hashes}],
|
||||
}
|
||||
file_info = {"name": "model.safetensors", "sizeKB": 1000, "hashes": {"SHA256": "abc123"}}
|
||||
file_info = {"name": "model.safetensors", "sizeKB": 1000, "hashes": hashes}
|
||||
|
||||
metadata = model_cls.from_civitai_info(version_info, file_info, "/test/model.safetensors")
|
||||
|
||||
assert metadata.autov3 is None
|
||||
|
||||
def test_normalize_autov3(self):
|
||||
from py.utils.models import normalize_autov3
|
||||
|
||||
assert normalize_autov3("ABCDEF1234567890ABCDEF") == "abcdef123456"
|
||||
assert normalize_autov3("abcdef123456") == "abcdef123456"
|
||||
assert normalize_autov3("abc") is None
|
||||
assert normalize_autov3(123) is None
|
||||
assert normalize_autov3(None) is None
|
||||
assert normalize_autov3("E3B0C44298FC1C149AFB") is None # empty-hash placeholder
|
||||
|
||||
def test_autov3_from_civitai_files_matches_sha256_case_insensitively(self):
|
||||
from py.utils.models import autov3_from_civitai_files
|
||||
|
||||
|
||||
Reference in New Issue
Block a user