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
+97 -6
View File
@@ -63,6 +63,15 @@ def _is_hidden_relative_path(rel_path: str) -> bool:
return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/"))
def _file_name_stem(file_path: str) -> str:
"""Return the extension-free file name of a normalized model path.
``file_name`` cache/sidecar fields are defined as the on-disk stem, so this
is the authoritative value to compare stored names against (issue #1112).
"""
return os.path.splitext(os.path.basename(file_path))[0]
# Maps a scanner model type to the manager page type used in progress
# broadcasts (e.g. 'lora' -> 'loras').
PAGE_TYPE_MAP = {
@@ -1076,6 +1085,26 @@ class ModelScanner:
# Track found files and new files
found_paths = set()
new_files = []
# Cached entries whose stored file_name no longer matches the file
# on disk (e.g. dotted stems truncated by the legacy .civitai.info
# migration, issue #1112). Repaired in place after the walk; the
# list stays empty on a clean library, so a no-change reconcile
# only pays one string compare per cached file.
stale_paths: List[str] = []
stale_seen: Set[str] = set()
def mark_stale_if_needed(cached_path: str) -> None:
"""Queue a cached path for file_name repair when it drifted."""
if cached_path in stale_seen:
return
item = path_to_item.get(cached_path)
if item is None:
return
if item.get("file_name") == _file_name_stem(cached_path):
return
stale_seen.add(cached_path)
stale_paths.append(cached_path)
visited_real_paths = set()
discovered_real_files = set()
discovered_folders: Set[str] = set()
@@ -1110,6 +1139,7 @@ class ModelScanner:
# Check if this file is already in cache
if file_path in cached_paths:
found_paths.add(file_path)
mark_stale_if_needed(file_path)
continue
# Only a cache miss needs the physical path, so the
@@ -1120,6 +1150,7 @@ class ModelScanner:
cached_real_match = lookup_cached_real_path(real_file_path)
if cached_real_match:
found_paths.add(cached_real_match)
mark_stale_if_needed(cached_real_match)
continue
if file_path in self._excluded_models:
@@ -1132,6 +1163,7 @@ class ModelScanner:
for cached_path in cached_paths:
if cached_path.lower() == lower_path:
found_paths.add(cached_path)
mark_stale_if_needed(cached_path)
matched = True
break
if matched:
@@ -1242,7 +1274,57 @@ class ModelScanner:
elapsed_seconds=time.time() - start_time,
)
return
# Repair rows whose file_name drifted from the file on disk. Only
# mismatching entries are re-read here, so a clean library never
# touches metadata during a refresh. Each repair goes through the
# single-row update path: load_metadata() normalizes the sidecar
# (MetadataManager._normalize_metadata_paths) and
# _sync_cache_from_metadata_impl() rewrites one targeted SQL delta
# instead of a full cache save, and the mismatch is gone
# afterwards, so the work never repeats (issue #1112).
total_repaired = 0
if stale_paths:
logger.info(
"%s Scanner: Repairing %d cached entries whose file_name no longer matches the file on disk",
self.model_type.capitalize(),
len(stale_paths),
)
for path in stale_paths:
if self.is_cancelled():
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile repair cancelled")
break
try:
metadata, _should_skip = await MetadataManager.load_metadata(
path, self.model_class
)
if metadata is None:
# Missing or corrupt sidecar: keep the existing row
# so a full rebuild can recreate the metadata from
# .civitai.info (or defaults) without losing cached
# fields such as tags or civitai data.
logger.debug(
"%s Scanner: Leaving %s unchanged (no usable metadata to repair from)",
self.model_type.capitalize(),
path,
)
continue
payload = metadata.to_dict()
unknown_fields = getattr(metadata, "_unknown_fields", None)
if isinstance(unknown_fields, dict):
payload.update(unknown_fields)
if await self._sync_cache_from_metadata_impl(path, payload):
total_repaired += 1
except Exception as exc:
logger.warning(
"%s Scanner: Failed to repair file_name for %s: %s",
self.model_type.capitalize(),
path,
exc,
)
# Find missing files (in cache but not in filesystem)
missing_files = cached_paths - found_paths
total_removed = 0
@@ -1323,7 +1405,11 @@ class ModelScanner:
elif folders_changed:
await self._persist_current_cache()
logger.info(f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in {time.time() - start_time:.2f} seconds. Added {total_added}, removed {total_removed} models.")
logger.info(
f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in "
f"{time.time() - start_time:.2f} seconds. Added {total_added}, "
f"removed {total_removed}, repaired {total_repaired} models."
)
await self._broadcast_scan_progress(
'completed', 'process_new', 100, False,
added=total_added, removed=total_removed,
@@ -1552,11 +1638,16 @@ class ModelScanner:
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
if file_info:
file_name = os.path.splitext(os.path.basename(file_path))[0]
file_info['name'] = file_name
local_stem = os.path.splitext(os.path.basename(file_path))[0]
# from_civitai_info expects an API-shaped file entry and
# strips one extension itself, so hand it the real
# basename: passing the already extension-free stem made
# it cut dotted names at their last dot ("lora-sd1.5-..."
# became "lora-sd1", issue #1112).
file_info['name'] = os.path.basename(file_path)
metadata = cast(Any, self.model_class).from_civitai_info(version_info, file_info, file_path)
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
metadata.preview_url = find_preview_file(local_stem, os.path.dirname(file_path))
await MetadataManager.save_metadata(file_path, metadata)
logger.info(f"Created metadata from .civitai.info for {file_path} (Reason: .civitai.info was found but .metadata.json was missing)")
except Exception as e: