fix(organize): stop keyword-dump tags from becoming folder names (#1119)

CivitAI tags are normally short single-concept labels, but some uploaders
pack their entire keyword list into one tag. The model in #1119 carries
"lora, character, rosie, irish, ... face" as a single 181-character tag.
Priority resolution matches aliases by exact equality, so that tag matched
nothing and resolve_priority_tag_for_model fell back to tags[0] -- the blob.
With the default "{base_model}/{first_tag}" template the model was filed
under "Krea 2/<181-character blob>/", and the full path plus the
".civitai.info" sidecar and the preview images next to it ran into the
Windows MAX_PATH limit.

Tags also bypassed sanitization on the way into a path: both
calculate_relative_path_for_model and DownloadManager._calculate_relative_path
sanitized model_name and version_name but interpolated {first_tag} verbatim,
so a tag containing "/" or ":" silently produced nested or illegal folders.

Two changes:

- The fallback skips tags that cannot serve as a folder name.
  is_usable_path_tag rejects comma-separated keyword dumps and tags longer
  than MAX_PATH_TAG_LENGTH; the resolver returns "" when nothing usable is
  left, which callers already render as "no tags". Whole-tag priority
  matching is untouched, so existing priority configurations behave the
  same.
- sanitize_folder_name gains an optional max_length, and every tag-derived
  segment now goes through it. Tags are capped at MAX_PATH_TAG_LENGTH, model
  and version names at MAX_FOLDER_NAME_LENGTH, and rendered filename stems at
  MAX_FILENAME_STEM_LENGTH.

For the reported model the folder becomes "Krea 2/base model" instead of the
blob, and the full path drops from 235 to 64 characters.

Existing libraries are not migrated up front: a path is only recomputed on
download, on an auto-organize run or when a filename template is applied, and
values already inside the caps are left byte-identical. Models previously
filed under a keyword-dump folder move on the next auto-organize run.
This commit is contained in:
Will Miao
2026-09-23 13:16:26 +08:00
parent 521531111a
commit 0ada32d0c7
9 changed files with 318 additions and 16 deletions
@@ -238,6 +238,47 @@ async def test_successful_download_uses_defaults(
assert captured["download_urls"] == ["https://example.invalid/file.safetensors"]
def test_calculate_relative_path_ignores_keyword_dump_tag():
"""The #1119 download flow: a keyword-dump tag must not become a folder."""
keyword_dump = (
"lora, character, rosie, irish, redhead, auburn, freckles, green eyes, "
"curly hair, woman, female, photorealistic, realistic, krea2, dark beast, "
"kreativity, nsfw, nude, portrait, face"
)
manager = DownloadManager()
relative_path = manager._calculate_relative_path(
{
"baseModel": "BaseModel",
"creator": {"username": "mad_macs"},
"name": "v1.2",
"model": {"name": "Rosie", "tags": [keyword_dump, "base model"]},
},
"lora",
)
assert relative_path == "MappedModel/base model"
assert keyword_dump not in relative_path
assert len(relative_path) < 50
def test_calculate_relative_path_sanitizes_tag_segment():
"""A tag with path separators must not create nested folders."""
manager = DownloadManager()
relative_path = manager._calculate_relative_path(
{
"baseModel": "BaseModel",
"creator": {"username": "author"},
"name": "v1.2",
"model": {"name": "Rosie", "tags": ["a/b:c"]},
},
"lora",
)
assert relative_path == "MappedModel/a_b_c"
@pytest.mark.asyncio
async def test_download_accepts_enhancement_lora_primary_file(
monkeypatch, scanners, metadata_provider, tmp_path
+47
View File
@@ -365,6 +365,53 @@ def test_download_path_template_unknown_type_is_flat(manager):
assert manager.get_download_path_template("not-a-model-type") == ""
# Real CivitAI data for the model reported in issue #1119: the uploader dumped
# a whole keyword list into a single tag.
KEYWORD_DUMP_TAG = (
"lora, character, rosie, irish, redhead, auburn, freckles, green eyes, "
"curly hair, woman, female, photorealistic, realistic, krea2, dark beast, "
"kreativity, nsfw, nude, portrait, face"
)
def test_resolve_priority_tag_prefers_configured_priority(manager):
# Priority order from CIVITAI_MODEL_TAGS: "character" precedes "anime".
assert manager.resolve_priority_tag_for_model(["anime", "character"], "lora") == (
"character"
)
def test_resolve_priority_tag_falls_back_to_first_usable_tag(manager):
assert (
manager.resolve_priority_tag_for_model(["portrait", "anime-ish"], "lora")
== "portrait"
)
def test_resolve_priority_tag_skips_keyword_dump_tag(manager):
"""A keyword-dump tag must not be used as a folder name (#1119)."""
assert manager.resolve_priority_tag_for_model([KEYWORD_DUMP_TAG], "lora") == ""
def test_resolve_priority_tag_skips_keyword_dump_and_uses_next_tag(manager):
assert (
manager.resolve_priority_tag_for_model([KEYWORD_DUMP_TAG, "portrait"], "lora")
== "portrait"
)
def test_resolve_priority_tag_skips_unusable_tags(manager):
overlong_tag = "x" * 51
assert manager.resolve_priority_tag_for_model([overlong_tag], "lora") == ""
assert manager.resolve_priority_tag_for_model([overlong_tag, " "], "lora") == ""
# Non-string entries never win the fallback.
assert manager.resolve_priority_tag_for_model([None, 42], "lora") == ""
# A tag at the length budget is still accepted and stripped.
assert manager.resolve_priority_tag_for_model(["x" * 50], "lora") == "x" * 50
assert manager.resolve_priority_tag_for_model([" portrait "], "lora") == "portrait"
def test_auto_set_default_roots(manager):
# Clear any previously auto-set values to test fresh behavior
manager.settings["default_lora_root"] = ""
+121
View File
@@ -2,6 +2,11 @@ import pytest
from py.services.settings_manager import SettingsManager, get_settings_manager
from py.services.service_registry import ServiceRegistry
from py.utils.constants import (
MAX_FILENAME_STEM_LENGTH,
MAX_FOLDER_NAME_LENGTH,
MAX_PATH_TAG_LENGTH,
)
from py.utils.utils import (
calculate_filename_for_model,
calculate_recipe_fingerprint,
@@ -12,6 +17,15 @@ from py.utils.utils import (
)
# Real CivitAI data for the model reported in issue #1119: the uploader dumped
# a whole keyword list into a single tag.
KEYWORD_DUMP_TAG = (
"lora, character, rosie, irish, redhead, auburn, freckles, green eyes, "
"curly hair, woman, female, photorealistic, realistic, krea2, dark beast, "
"kreativity, nsfw, nude, portrait, face"
)
class _FakeCache:
def __init__(self, items):
self.raw_data = list(items)
@@ -147,6 +161,66 @@ def test_calculate_relative_path_sanitizes_double_slashes(isolated_settings):
assert relative_path == "no tags/Author"
def test_calculate_relative_path_ignores_keyword_dump_tag(isolated_settings):
"""A tag holding a whole keyword list must not become a folder name (#1119)."""
model_data = {"base_model": "Krea 2", "tags": [KEYWORD_DUMP_TAG]}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert relative_path == "Krea 2/no tags"
def test_calculate_relative_path_uses_next_usable_tag(isolated_settings):
"""Unusable tags are skipped instead of hijacking the folder name (#1119)."""
model_data = {"base_model": "Krea 2", "tags": [KEYWORD_DUMP_TAG, "portrait"]}
assert calculate_relative_path_for_model(model_data, "lora") == "Krea 2/portrait"
def test_calculate_relative_path_sanitizes_tag_segment(isolated_settings):
"""A tag with path separators must not create nested folders."""
model_data = {"base_model": "SDXL", "tags": ["a/b:c"]}
assert calculate_relative_path_for_model(model_data, "lora") == "SDXL/a_b_c"
def test_calculate_relative_path_caps_tag_segment(isolated_settings):
"""A long configured priority tag is truncated to the tag length budget."""
long_tag = "y" * 80
isolated_settings["priority_tags"] = {"lora": long_tag}
model_data = {"base_model": "SDXL", "tags": [long_tag]}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert relative_path == "SDXL/" + "y" * MAX_PATH_TAG_LENGTH
def test_calculate_relative_path_keeps_tag_within_budget(isolated_settings):
model_data = {"base_model": "SDXL", "tags": ["t" * 40]}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert relative_path == "SDXL/" + "t" * 40
def test_calculate_relative_path_caps_model_and_version_names(isolated_settings):
isolated_settings["download_path_templates"]["lora"] = "{model_name}/{version_name}"
model_data = {
"model_name": "m" * 300,
"base_model": "SDXL",
"tags": [],
"civitai": {"id": 1, "name": "v" * 300, "creator": {"username": "Creator"}},
}
relative_path = calculate_relative_path_for_model(model_data, "lora")
assert relative_path == (
"m" * MAX_FOLDER_NAME_LENGTH + "/" + "v" * MAX_FOLDER_NAME_LENGTH
)
def test_calculate_recipe_fingerprint_filters_and_sorts():
loras = [
{"hash": "ABC", "strength": 0.1234},
@@ -304,6 +378,32 @@ def test_calculate_filename_original_name_falls_back_to_file_name(isolated_setti
assert calculate_filename_for_model(model_data, "lora") == "legacy-name-0123456789"
def test_calculate_filename_drops_keyword_dump_tag(isolated_settings):
"""The keyword-dump tag collapses instead of filling the filename (#1119)."""
_set_filename_templates(isolated_settings, "{base_model}-{first_tag}")
model_data = {
"base_model": "Krea 2",
"tags": [KEYWORD_DUMP_TAG],
"file_path": "/models/V1.safetensors",
}
assert calculate_filename_for_model(model_data, "lora") == "Krea 2"
def test_calculate_filename_caps_rendered_stem(isolated_settings):
_set_filename_templates(isolated_settings, "{model_name}")
model_data = {
"model_name": "m" * 400,
"file_path": "/models/V1.safetensors",
}
result = calculate_filename_for_model(model_data, "lora")
assert len(result) == MAX_FILENAME_STEM_LENGTH
@pytest.mark.parametrize(
"original, expected",
[
@@ -318,6 +418,27 @@ def test_sanitize_folder_name(original, expected):
assert sanitize_folder_name(original) == expected
def test_sanitize_folder_name_without_max_length_is_unbounded():
assert sanitize_folder_name("x" * 300) == "x" * 300
@pytest.mark.parametrize(
"original, max_length, expected",
[
("abcdefghij", 4, "abcd"),
# Re-trim separators and spaces exposed by the cut.
("abc...defg", 4, "abc"),
("abcdefg hij", 8, "abcdefg"),
# Shorter than the cap is returned untouched.
("short", 10, "short"),
# A cut that leaves only separators falls back to "unnamed".
("...abcdef", 3, "unnamed"),
],
)
def test_sanitize_folder_name_truncates_to_max_length(original, max_length, expected):
assert sanitize_folder_name(original, max_length=max_length) == expected
def test_get_lora_info_absolute_bare_name(mock_lora_scanner):
mock_lora_scanner([
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {"trainedWords": ["trigger1"]}},