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:
Will Miao
2026-08-08 15:20:43 +08:00
parent 97b9b1f62b
commit 6fcdeb799d
6 changed files with 197 additions and 18 deletions

View File

@@ -18,7 +18,7 @@ from ..utils.constants import (
VALID_LORA_TYPES, VALID_LORA_TYPES,
) )
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url 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.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name from ..utils.utils import sanitize_folder_name
from ..utils.exif_utils import ExifUtils 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})", "error": f"Zip archive does not contain any supported model files ({supported_text})",
} }
actual_file_paths = extracted_paths 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: try:
os.remove(save_path) os.remove(save_path)
except OSError as exc: except OSError as exc:
@@ -2374,6 +2378,16 @@ class DownloadManager:
sha256 = await calculate_sha256(file_path) sha256 = await calculate_sha256(file_path)
if sha256: if sha256:
entry.sha256 = sha256.lower() 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) entries.append(entry)
return entries return entries

View File

@@ -6,15 +6,29 @@ from .constants import INVALID_AUTOV3_EMPTY_HASH
from .model_utils import determine_base_model 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]: def autov3_from_civitai_files(civitai_data: Optional[Dict], sha256: str) -> Optional[str]:
"""Extract the AutoV3 hash from Civitai metadata for the matching file. """Extract the AutoV3 hash from Civitai metadata for the matching file.
Civitai versions can ship multiple files; the AutoV3 hash is only valid Civitai versions can ship multiple files; the AutoV3 hash is only valid
for the file whose ``hashes.SHA256`` equals the local model's sha256. for the file whose ``hashes.SHA256`` equals the local model's sha256.
Matching is case-insensitive. The value is the first 12 characters of Matching is case-insensitive.
Civitai's AutoV3 hash, lowercased. The empty-string SHA256 placeholder
(``e3b0c44298fc``) is rejected — it is a repackaging-tool artifact, not a
real hash.
Returns ``None`` when no Civitai data, no matching file, or no usable Returns ``None`` when no Civitai data, no matching file, or no usable
AutoV3 hash is available. 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 {} hashes = file_info.get("hashes") or {}
file_sha = (hashes.get("SHA256") or "").lower() file_sha = (hashes.get("SHA256") or "").lower()
if file_sha and file_sha == target_sha: if file_sha and file_sha == target_sha:
auto_v3 = hashes.get("AutoV3") return normalize_autov3(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 None return None
@@ -264,7 +274,8 @@ class LoraMetadata(BaseModelMetadata):
civitai=version_info, civitai=version_info,
tags=tags, tags=tags,
modelDescription=description, 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, sub_type=sub_type,
tags=tags, tags=tags,
modelDescription=description, 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, sub_type=sub_type,
tags=tags, tags=tags,
modelDescription=description, 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")),
) )

View 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

View File

@@ -100,6 +100,7 @@ async def test_execute_download_uses_rewritten_civitai_preview(monkeypatch, tmp_
self.sha256 = "sha256" self.sha256 = "sha256"
self.file_name = path.stem self.file_name = path.stem
self.preview_url = None self.preview_url = None
self.autov3 = None
self.preview_nsfw_level = None self.preview_nsfw_level = None
def generate_unique_filename(self, *_args, **_kwargs): 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.sha256 = "sha256"
self.file_name = path.stem self.file_name = path.stem
self.preview_url = None self.preview_url = None
self.autov3 = None
self.preview_nsfw_level = None self.preview_nsfw_level = None
def generate_unique_filename(self, *_args, **_kwargs): 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.sha256 = "sha256"
self.file_name = path.stem self.file_name = path.stem
self.preview_url = None self.preview_url = None
self.autov3 = None
self.preview_nsfw_level = None self.preview_nsfw_level = None
def generate_unique_filename(self, *_args, **_kwargs): def generate_unique_filename(self, *_args, **_kwargs):

View File

@@ -75,6 +75,7 @@ async def test_execute_download_retries_urls(monkeypatch, tmp_path):
self.sha256 = "sha256" self.sha256 = "sha256"
self.file_name = path.stem self.file_name = path.stem
self.preview_url = None self.preview_url = None
self.autov3 = None
def generate_unique_filename(self, *_args, **_kwargs): def generate_unique_filename(self, *_args, **_kwargs):
return os.path.basename(self.file_path) 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.sha256 = "sha256"
self.file_name = path.stem self.file_name = path.stem
self.preview_url = None self.preview_url = None
self.autov3 = None
def generate_unique_filename(self, *_args, **_kwargs): def generate_unique_filename(self, *_args, **_kwargs):
return os.path.basename(self.file_path) 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.sha256 = "sha256"
self.file_name = path.stem self.file_name = path.stem
self.preview_url = None self.preview_url = None
self.autov3 = None
def generate_unique_filename(self, *_args, **_kwargs): def generate_unique_filename(self, *_args, **_kwargs):
return os.path.basename(self.file_path) 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.sha256 = "sha256"
self.file_name = path.stem self.file_name = path.stem
self.preview_url = None self.preview_url = None
self.autov3 = None
self.preview_nsfw_level = 0 self.preview_nsfw_level = 0
self.sub_type = "checkpoint" self.sub_type = "checkpoint"
@@ -446,6 +450,7 @@ async def test_execute_download_extracts_zip_single_model(monkeypatch, tmp_path)
self.sha256 = "sha256" self.sha256 = "sha256"
self.file_name = path.stem self.file_name = path.stem
self.preview_url = None self.preview_url = None
self.autov3 = None
def generate_unique_filename(self, *_args, **_kwargs): def generate_unique_filename(self, *_args, **_kwargs):
return os.path.basename(self.file_path) 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} return {"file_path": self.file_path}
metadata = DummyMetadata(zip_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": []} version_info = {"images": []}
download_urls = ["https://example.invalid/model.zip"] 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) assert saved_call.args[0] == str(extracted)
# SHA256 comes from metadata (API value), not recalculated # SHA256 comes from metadata (API value), not recalculated
assert saved_call.args[1].sha256 == "sha256" 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 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.sha256 = "sha256"
self.file_name = path.stem self.file_name = path.stem
self.preview_url = None self.preview_url = None
self.autov3 = None
def generate_unique_filename(self, *_args, **_kwargs): def generate_unique_filename(self, *_args, **_kwargs):
return os.path.basename(self.file_path) 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.sha256 = "sha256"
self.file_name = path.stem self.file_name = path.stem
self.preview_url = None self.preview_url = None
self.autov3 = None
def generate_unique_filename(self, *_args, **_kwargs): def generate_unique_filename(self, *_args, **_kwargs):
return os.path.basename(self.file_path) 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.sha256 = "sha256"
self.file_name = path.stem self.file_name = path.stem
self.preview_url = None self.preview_url = None
self.autov3 = None
def generate_unique_filename(self, *_args, **_kwargs): def generate_unique_filename(self, *_args, **_kwargs):
return os.path.basename(self.file_path) 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.sha256 = "sha256"
self.file_name = path.stem self.file_name = path.stem
self.preview_url = None self.preview_url = None
self.autov3 = None
def generate_unique_filename(self, *_args, **_kwargs): def generate_unique_filename(self, *_args, **_kwargs):
return "renamed.safetensors" return "renamed.safetensors"
@@ -1341,6 +1356,7 @@ async def test_execute_download_rejects_conflicting_aria2_partial_path(tmp_path)
self.sha256 = "sha256" self.sha256 = "sha256"
self.file_name = path.stem self.file_name = path.stem
self.preview_url = None self.preview_url = None
self.autov3 = None
def generate_unique_filename(self, *_args, **_kwargs): def generate_unique_filename(self, *_args, **_kwargs):
raise AssertionError("should not rename") 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.sha256 = "sha256"
self.file_name = path.stem self.file_name = path.stem
self.preview_url = None self.preview_url = None
self.autov3 = None
def generate_unique_filename(self, *_args, **_kwargs): def generate_unique_filename(self, *_args, **_kwargs):
raise AssertionError("should not rename") raise AssertionError("should not rename")

View File

@@ -175,13 +175,14 @@ class TestBaseModelMetadataAutov3:
@pytest.mark.parametrize("model_cls", [LoraMetadata, CheckpointMetadata, EmbeddingMetadata]) @pytest.mark.parametrize("model_cls", [LoraMetadata, CheckpointMetadata, EmbeddingMetadata])
def test_from_civitai_info_extracts_autov3(self, model_cls): def test_from_civitai_info_extracts_autov3(self, model_cls):
# Civitai versions can ship multiple files; AutoV3 is taken from the # The downloaded file is exactly ``file_info``, so AutoV3 is read
# file whose SHA256 matches the downloaded file. # directly from its own hashes — no SHA256 cross-matching against
# version_info.files (which may hold sibling files of the version).
version_info = { version_info = {
"baseModel": "SDXL", "baseModel": "SDXL",
"model": {"name": "Test", "description": "", "tags": []}, "model": {"name": "Test", "description": "", "tags": []},
"files": [ "files": [
{"name": "other.safetensors", "sizeKB": 100, "hashes": {"SHA256": "zzz999"}}, {"name": "other.safetensors", "sizeKB": 100, "hashes": {"SHA256": "zzz999", "AutoV3": "999999999999"}},
{ {
"name": "model.safetensors", "name": "model.safetensors",
"sizeKB": 1000, "sizeKB": 1000,
@@ -192,7 +193,27 @@ class TestBaseModelMetadataAutov3:
file_info = { file_info = {
"name": "model.safetensors", "name": "model.safetensors",
"sizeKB": 1000, "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") 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": "abc"},
{"SHA256": "abc123", "AutoV3": 123}, {"SHA256": "abc123", "AutoV3": 123},
{"SHA256": "abc123", "AutoV3": None}, {"SHA256": "abc123", "AutoV3": None},
{"SHA256": "abc123", "AutoV3": "E3B0C44298FC1C149AFB..."},
], ],
) )
@pytest.mark.parametrize("model_cls", [LoraMetadata, CheckpointMetadata, EmbeddingMetadata]) @pytest.mark.parametrize("model_cls", [LoraMetadata, CheckpointMetadata, EmbeddingMetadata])
@@ -216,12 +238,22 @@ class TestBaseModelMetadataAutov3:
"model": {"name": "Test", "description": "", "tags": []}, "model": {"name": "Test", "description": "", "tags": []},
"files": [{"name": "model.safetensors", "sizeKB": 1000, "hashes": hashes}], "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") metadata = model_cls.from_civitai_info(version_info, file_info, "/test/model.safetensors")
assert metadata.autov3 is None 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): def test_autov3_from_civitai_files_matches_sha256_case_insensitively(self):
from py.utils.models import autov3_from_civitai_files from py.utils.models import autov3_from_civitai_files