fix(scanner): stop truncating dotted model file names (#1112)

A LoRA named `lora-sd1.5-backlight_slider_v10.safetensors` showed up in the
manager as `lora-sd1`, hid itself from searches for the rest of its name, and
collapsed into the same lora syntax tag as every sibling sharing the prefix.

The name was cut twice.  `_process_model_file()` imports a third-party
`.civitai.info` sidecar by handing `from_civitai_info()` the local stem with
the extension already stripped, and the builder then stripped a second
"extension" from it -- `os.path.splitext` reads everything after the last dot
as one, so the version dot in `1.5` ended the name.  The download path never
hit this because API filenames keep their extension and only need one strip.

Pass the real basename from the migration site, and make the builder strip
only a recognized model extension (`strip_model_extension`), so both input
shapes resolve to the same stem.  The `model_name` fallback that reused the
same expression is fixed with it: on a sidecar without `model.name` the
display name was truncated too.

Libraries already corrupted do not heal on their own: the incremental Refresh
skips paths already in the cache (only a full rebuild reloads metadata) and
startup hydrates rows from SQLite as-is, so the wrong name survives restarts.
Reconcile now compares each cached row against the stem of its file path --
one string compare per file and no extra syscall, so a clean library pays
nothing -- and repairs mismatching rows through `load_metadata()` (which
normalizes the sidecar) and the existing in-place `_sync_cache_from_metadata_impl()`
path, which writes a targeted single-row SQL delta instead of a full save.
Repairs are one-shot, and a missing or corrupt sidecar keeps its row so a full
rebuild can recreate it without losing tags or civitai data.

Tests: the builder keeps dotted stems for all four model classes and still
strips real extensions; the migration writes the full local name to the
sidecar; and reconcile repairs memory, sidecar and SQLite row, runs exactly
once, and never reads metadata on a clean library.
This commit is contained in:
Will Miao
2026-09-15 09:07:56 +08:00
parent 942717f0b6
commit 2ceb1e2850
5 changed files with 475 additions and 15 deletions
+35 -9
View File
@@ -2,7 +2,11 @@ from dataclasses import dataclass, asdict, field
from typing import Callable, Dict, Optional, List, Any
from datetime import datetime
import os
from .constants import CIVITAI_TYPE_TO_OTHER_SUB_TYPE, INVALID_AUTOV3_EMPTY_HASH
from .constants import (
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
INVALID_AUTOV3_EMPTY_HASH,
MODEL_FILE_EXTENSIONS,
)
from .model_utils import determine_base_model
@@ -46,6 +50,24 @@ def autov3_from_civitai_files(civitai_data: Optional[Dict[str, Any]], sha256: st
return None
def strip_model_extension(file_name: str) -> str:
"""Strip a recognized model file extension, leaving dotted stems intact.
``os.path.splitext`` treats everything after the last dot as an extension,
so applying it to an already extension-free name truncates dotted stems:
``lora-sd1.5-backlight_slider_v10`` becomes ``lora-sd1``. API filenames keep
their extension and need one strip, while migration paths (``.civitai.info``)
pass the local stem as-is, so only remove a suffix that is a known model
extension and both inputs resolve to the same stem (issue #1112).
"""
if not file_name:
return file_name
stem, extension = os.path.splitext(file_name)
if extension.lower() in MODEL_FILE_EXTENSIONS:
return stem
return file_name
@dataclass
class BaseModelMetadata:
"""Base class for all model metadata structures"""
@@ -241,6 +263,7 @@ class LoraMetadata(BaseModelMetadata):
) -> "LoraMetadata":
"""Create LoraMetadata instance from Civitai version info"""
file_name = file_info.get("name", "")
base_name = strip_model_extension(file_name)
base_model = determine_base_model(version_info.get("baseModel", ""))
# Extract tags and description if available
@@ -255,8 +278,8 @@ class LoraMetadata(BaseModelMetadata):
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
return cls(
file_name=os.path.splitext(file_name)[0],
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_name=base_name,
model_name=model_data.get("name", base_name),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),
@@ -285,6 +308,7 @@ class CheckpointMetadata(BaseModelMetadata):
) -> "CheckpointMetadata":
"""Create CheckpointMetadata instance from Civitai version info"""
file_name = file_info.get("name", "")
base_name = strip_model_extension(file_name)
base_model = determine_base_model(version_info.get("baseModel", ""))
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
sub_type = version_info.get("type", "checkpoint")
@@ -299,8 +323,8 @@ class CheckpointMetadata(BaseModelMetadata):
description = model_data["description"]
return cls(
file_name=os.path.splitext(file_name)[0],
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_name=base_name,
model_name=model_data.get("name", base_name),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),
@@ -336,6 +360,7 @@ class OtherModelMetadata(BaseModelMetadata):
) -> "OtherModelMetadata":
"""Create OtherModelMetadata instance from Civitai version info"""
file_name = file_info.get("name", "")
base_name = strip_model_extension(file_name)
base_model = determine_base_model(version_info.get("baseModel", ""))
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
# Map the CivitAI model type onto our sub_types; unknown types keep the
@@ -354,8 +379,8 @@ class OtherModelMetadata(BaseModelMetadata):
description = model_data["description"]
return cls(
file_name=os.path.splitext(file_name)[0],
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_name=base_name,
model_name=model_data.get("name", base_name),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),
@@ -385,6 +410,7 @@ class EmbeddingMetadata(BaseModelMetadata):
) -> "EmbeddingMetadata":
"""Create EmbeddingMetadata instance from Civitai version info"""
file_name = file_info.get("name", "")
base_name = strip_model_extension(file_name)
base_model = determine_base_model(version_info.get("baseModel", ""))
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
sub_type = version_info.get("type", "embedding")
@@ -399,8 +425,8 @@ class EmbeddingMetadata(BaseModelMetadata):
description = model_data["description"]
return cls(
file_name=os.path.splitext(file_name)[0],
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_name=base_name,
model_name=model_data.get("name", base_name),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),